0
0
mirror of https://github.com/signalapp/Signal-Server.git synced 2024-09-20 12:02:18 +02:00

Enhance device management API.

1. Put a limit on the number of registered devices per account.

2. Support removing devices.

3. Support device names and created dates.

4. Support enumerating devices.

// FREEBIE
This commit is contained in:
Moxie Marlinspike 2015-06-19 21:41:22 -07:00
parent 75ee398633
commit 52ce7d6935
17 changed files with 251 additions and 16 deletions

View File

@ -46,7 +46,9 @@ import org.whispersystems.textsecuregcm.federation.FederatedClientManager;
import org.whispersystems.textsecuregcm.federation.FederatedPeer;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle;
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.CpuUsageGauge;
import org.whispersystems.textsecuregcm.metrics.FreeMemoryGauge;
@ -246,6 +248,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(new IOExceptionMapper());
environment.jersey().register(new RateLimitExceededExceptionMapper());
environment.jersey().register(new InvalidWebsocketAddressExceptionMapper());
environment.jersey().register(new DeviceLimitExceededExceptionMapper());
environment.metrics().register(name(CpuUsageGauge.class, "cpu"), new CpuUsageGauge());
environment.metrics().register(name(FreeMemoryGauge.class, "free_memory"), new FreeMemoryGauge());

View File

@ -282,6 +282,9 @@ public class AccountController {
device.setSignalingKey(accountAttributes.getSignalingKey());
device.setFetchesMessages(accountAttributes.getFetchesMessages());
device.setRegistrationId(accountAttributes.getRegistrationId());
device.setName(accountAttributes.getName());
device.setCreated(System.currentTimeMillis());
device.setLastSeen(Util.todayInMillis());
Account account = new Account();
account.setNumber(number);

View File

@ -25,6 +25,8 @@ import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.AuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
import org.whispersystems.textsecuregcm.entities.DeviceInfoList;
import org.whispersystems.textsecuregcm.entities.DeviceResponse;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
@ -36,6 +38,7 @@ import org.whispersystems.textsecuregcm.util.VerificationCode;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT;
@ -48,6 +51,8 @@ import javax.ws.rs.core.Response;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.LinkedList;
import java.util.List;
import io.dropwizard.auth.Auth;
@ -56,6 +61,8 @@ public class DeviceController {
private final Logger logger = LoggerFactory.getLogger(DeviceController.class);
private static final int MAX_DEVICES = 3;
private final PendingDevicesManager pendingDevices;
private final AccountsManager accounts;
private final RateLimiters rateLimiters;
@ -69,15 +76,41 @@ public class DeviceController {
this.rateLimiters = rateLimiters;
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
public DeviceInfoList getDevices(@Auth Account account) {
List<DeviceInfo> devices = new LinkedList<>();
for (Device device : account.getDevices()) {
devices.add(new DeviceInfo(device.getId(), device.getName(),
device.getLastSeen(), device.getCreated()));
}
return new DeviceInfoList(devices);
}
@Timed
@DELETE
@Path("/{device_id}")
public void removeDevice(@Auth Account account, @PathParam("device_id") long deviceId) {
account.removeDevice(deviceId);
accounts.update(account);
}
@Timed
@GET
@Path("/provisioning/code")
@Produces(MediaType.APPLICATION_JSON)
public VerificationCode createDeviceToken(@Auth Account account)
throws RateLimitExceededException
throws RateLimitExceededException, DeviceLimitExceededException
{
rateLimiters.getAllocateDeviceLimiter().validate(account.getNumber());
if (account.getActiveDeviceCount() >= MAX_DEVICES) {
throw new DeviceLimitExceededException(account.getDevices().size(), MAX_DEVICES);
}
VerificationCode verificationCode = generateVerificationCode();
pendingDevices.store(account.getNumber(), verificationCode.getVerificationCode());
@ -92,7 +125,7 @@ public class DeviceController {
public DeviceResponse verifyDeviceToken(@PathParam("verification_code") String verificationCode,
@HeaderParam("Authorization") String authorizationHeader,
@Valid AccountAttributes accountAttributes)
throws RateLimitExceededException
throws RateLimitExceededException, DeviceLimitExceededException
{
try {
AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader);
@ -115,13 +148,19 @@ public class DeviceController {
throw new WebApplicationException(Response.status(403).build());
}
if (account.get().getActiveDeviceCount() >= MAX_DEVICES) {
throw new DeviceLimitExceededException(account.get().getDevices().size(), MAX_DEVICES);
}
Device device = new Device();
device.setName(accountAttributes.getName());
device.setAuthenticationCredentials(new AuthenticationCredentials(password));
device.setSignalingKey(accountAttributes.getSignalingKey());
device.setFetchesMessages(accountAttributes.getFetchesMessages());
device.setId(account.get().getNextDeviceId());
device.setRegistrationId(accountAttributes.getRegistrationId());
device.setLastSeen(Util.todayInMillis());
device.setCreated(System.currentTimeMillis());
account.get().addDevice(device);
accounts.update(account.get());

View File

@ -0,0 +1,21 @@
package org.whispersystems.textsecuregcm.controllers;
public class DeviceLimitExceededException extends Exception {
private final int currentDevices;
private final int maxDevices;
public DeviceLimitExceededException(int currentDevices, int maxDevices) {
this.currentDevices = currentDevices;
this.maxDevices = maxDevices;
}
public int getCurrentDevices() {
return currentDevices;
}
public int getMaxDevices() {
return maxDevices;
}
}

View File

@ -17,8 +17,12 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.Max;
public class AccountAttributes {
@JsonProperty
@ -31,12 +35,23 @@ public class AccountAttributes {
@JsonProperty
private int registrationId;
@JsonProperty
@Length(max = 50, message = "This field must be less than 50 characters")
private String name;
public AccountAttributes() {}
@VisibleForTesting
public AccountAttributes(String signalingKey, boolean fetchesMessages, int registrationId) {
this(signalingKey, fetchesMessages, registrationId, null);
}
@VisibleForTesting
public AccountAttributes(String signalingKey, boolean fetchesMessages, int registrationId, String name) {
this.signalingKey = signalingKey;
this.fetchesMessages = fetchesMessages;
this.registrationId = registrationId;
this.name = name;
}
public String getSignalingKey() {
@ -50,4 +65,8 @@ public class AccountAttributes {
public int getRegistrationId() {
return registrationId;
}
public String getName() {
return name;
}
}

View File

@ -0,0 +1,24 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
public class DeviceInfo {
@JsonProperty
private long id;
@JsonProperty
private String name;
@JsonProperty
private long lastSeen;
@JsonProperty
private long created;
public DeviceInfo(long id, String name, long lastSeen, long created) {
this.id = id;
this.name = name;
this.lastSeen = lastSeen;
this.created = created;
}
}

View File

@ -0,0 +1,15 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class DeviceInfoList {
@JsonProperty
private List<DeviceInfo> devices;
public DeviceInfoList(List<DeviceInfo> devices) {
this.devices = devices;
}
}

View File

@ -40,6 +40,6 @@ public class NonLimitedAccount extends Account {
@Override
public Optional<Device> getAuthenticatedDevice() {
return Optional.of(new Device(deviceId, null, null, null, null, null, null, false, 0, null, System.currentTimeMillis()));
return Optional.of(new Device(deviceId, null, null, null, null, null, null, null, false, 0, null, System.currentTimeMillis(), System.currentTimeMillis()));
}
}

View File

@ -0,0 +1,33 @@
package org.whispersystems.textsecuregcm.mappers;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.textsecuregcm.controllers.DeviceLimitExceededException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
@Provider
public class DeviceLimitExceededExceptionMapper implements ExceptionMapper<DeviceLimitExceededException> {
@Override
public Response toResponse(DeviceLimitExceededException exception) {
return Response.status(411)
.entity(new DeviceLimitExceededDetails(exception.getCurrentDevices(),
exception.getMaxDevices()))
.build();
}
private static class DeviceLimitExceededDetails {
@JsonProperty
private int current;
@JsonProperty
private int max;
public DeviceLimitExceededDetails(int current, int max) {
this.current = current;
this.max = max;
}
}
}

View File

@ -70,6 +70,10 @@ public class Account {
this.devices.add(device);
}
public void removeDevice(long deviceId) {
this.devices.remove(new Device(deviceId, null, null, null, null, null, null, null, false, 0, null, 0, 0));
}
public Set<Device> getDevices() {
return devices;
}

View File

@ -31,6 +31,9 @@ public class Device {
@JsonProperty
private long id;
@JsonProperty
private String name;
@JsonProperty
private String authToken;
@ -64,14 +67,19 @@ public class Device {
@JsonProperty
private long lastSeen;
@JsonProperty
private long created;
public Device() {}
public Device(long id, String authToken, String salt,
public Device(long id, String name, String authToken, String salt,
String signalingKey, String gcmId, String apnId,
String voipApnId, boolean fetchesMessages,
int registrationId, SignedPreKey signedPreKey, long lastSeen)
int registrationId, SignedPreKey signedPreKey,
long lastSeen, long created)
{
this.id = id;
this.name = name;
this.authToken = authToken;
this.salt = salt;
this.signalingKey = signalingKey;
@ -82,6 +90,7 @@ public class Device {
this.registrationId = registrationId;
this.signedPreKey = signedPreKey;
this.lastSeen = lastSeen;
this.created = created;
}
public String getApnId() {
@ -112,6 +121,14 @@ public class Device {
return lastSeen;
}
public void setCreated(long created) {
this.created = created;
}
public long getCreated() {
return this.created;
}
public String getGcmId() {
return gcmId;
}
@ -132,6 +149,14 @@ public class Device {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public void setAuthenticationCredentials(AuthenticationCredentials credentials) {
this.authToken = credentials.getHashedAuthenticationToken();
this.salt = credentials.getSalt();

View File

@ -2,6 +2,7 @@ package org.whispersystems.textsecuregcm.util;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
public class SystemMapper {
@ -11,6 +12,7 @@ public class SystemMapper {
static {
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
public static ObjectMapper getMapper() {

View File

@ -17,6 +17,7 @@
package org.whispersystems.textsecuregcm.tests.controllers;
import com.google.common.base.Optional;
import com.sun.jersey.api.client.ClientResponse;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@ -25,6 +26,7 @@ import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.DeviceResponse;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.PendingDevicesManager;
@ -34,8 +36,10 @@ import org.whispersystems.textsecuregcm.util.VerificationCode;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType;
import io.dropwizard.jersey.validation.ConstraintViolationExceptionMapper;
import io.dropwizard.testing.junit.ResourceTestRule;
import static org.fest.assertions.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.*;
public class DeviceControllerTest {
@ -56,10 +60,13 @@ public class DeviceControllerTest {
private RateLimiters rateLimiters = mock(RateLimiters.class );
private RateLimiter rateLimiter = mock(RateLimiter.class );
private Account account = mock(Account.class );
private Account maxedAccount = mock(Account.class);
@Rule
public final ResourceTestRule resources = ResourceTestRule.builder()
.addProvider(AuthHelper.getAuthenticator())
.addProvider(new DeviceLimitExceededExceptionMapper())
.addProvider(new ConstraintViolationExceptionMapper())
.addResource(new DumbVerificationDeviceController(pendingDevicesManager,
accountsManager,
rateLimiters))
@ -75,9 +82,12 @@ public class DeviceControllerTest {
when(rateLimiters.getVerifyDeviceLimiter()).thenReturn(rateLimiter);
when(account.getNextDeviceId()).thenReturn(42L);
when(maxedAccount.getActiveDeviceCount()).thenReturn(3);
when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of("5678901"));
when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of("1112223"));
when(accountsManager.get(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(account));
when(accountsManager.get(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(maxedAccount));
}
@Test
@ -98,4 +108,24 @@ public class DeviceControllerTest {
verify(pendingDevicesManager).remove(AuthHelper.VALID_NUMBER);
}
@Test
public void maxDevicesTest() throws Exception {
ClientResponse response = resources.client().resource("/v1/devices/provisioning/code")
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER_TWO, AuthHelper.VALID_PASSWORD_TWO))
.get(ClientResponse.class);
assertEquals(response.getStatus(), 411);
}
@Test
public void longNameTest() throws Exception {
ClientResponse response = resources.client().resource("/v1/devices/5678901")
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, "password1"))
.entity(new AccountAttributes("keykeykeykey", false, 1234, "this is a really long name that is longer than 80 characters"))
.type(MediaType.APPLICATION_JSON_TYPE)
.put(ClientResponse.class);
assertEquals(response.getStatus(), 422);
}
}

View File

@ -74,12 +74,12 @@ public class FederatedControllerTest {
@Before
public void setup() throws Exception {
Set<Device> singleDeviceList = new HashSet<Device>() {{
add(new Device(1, "foo", "bar", "baz", "isgcm", null, null, false, 111, null, System.currentTimeMillis()));
add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 111, null, System.currentTimeMillis(), System.currentTimeMillis()));
}};
Set<Device> multiDeviceList = new HashSet<Device>() {{
add(new Device(1, "foo", "bar", "baz", "isgcm", null, null, false, 222, null, System.currentTimeMillis()));
add(new Device(2, "foo", "bar", "baz", "isgcm", null, null, false, 333, null, System.currentTimeMillis()));
add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 222, null, System.currentTimeMillis(), System.currentTimeMillis()));
add(new Device(2, null, "foo", "bar", "baz", "isgcm", null, null, false, 333, null, System.currentTimeMillis(), System.currentTimeMillis()));
}};
Account singleDeviceAccount = new Account(SINGLE_DEVICE_RECIPIENT, singleDeviceList);

View File

@ -69,13 +69,13 @@ public class MessageControllerTest {
@Before
public void setup() throws Exception {
Set<Device> singleDeviceList = new HashSet<Device>() {{
add(new Device(1, "foo", "bar", "baz", "isgcm", null, null, false, 111, null, System.currentTimeMillis()));
add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 111, null, System.currentTimeMillis(), System.currentTimeMillis()));
}};
Set<Device> multiDeviceList = new HashSet<Device>() {{
add(new Device(1, "foo", "bar", "baz", "isgcm", null, null, false, 222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis()));
add(new Device(2, "foo", "bar", "baz", "isgcm", null, null, false, 333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis()));
add(new Device(3, "foo", "bar", "baz", "isgcm", null, null, false, 444, null, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)));
add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis()));
add(new Device(2, null, "foo", "bar", "baz", "isgcm", null, null, false, 333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis()));
add(new Device(3, null, "foo", "bar", "baz", "isgcm", null, null, false, 444, null, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31), System.currentTimeMillis()));
}};
Account singleDeviceAccount = new Account(SINGLE_DEVICE_RECIPIENT, singleDeviceList);

View File

@ -47,12 +47,12 @@ public class ReceiptControllerTest {
@Before
public void setup() throws Exception {
Set<Device> singleDeviceList = new HashSet<Device>() {{
add(new Device(1, "foo", "bar", "baz", "isgcm", null, null, false, 111, null, System.currentTimeMillis()));
add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 111, null, System.currentTimeMillis(), System.currentTimeMillis()));
}};
Set<Device> multiDeviceList = new HashSet<Device>() {{
add(new Device(1, "foo", "bar", "baz", "isgcm", null, null, false, 222, null, System.currentTimeMillis()));
add(new Device(2, "foo", "bar", "baz", "isgcm", null, null, false, 333, null, System.currentTimeMillis()));
add(new Device(1, null, "foo", "bar", "baz", "isgcm", null, null, false, 222, null, System.currentTimeMillis(), System.currentTimeMillis()));
add(new Device(2, null, "foo", "bar", "baz", "isgcm", null, null, false, 333, null, System.currentTimeMillis(), System.currentTimeMillis()));
}};
Account singleDeviceAccount = new Account(SINGLE_DEVICE_RECIPIENT, singleDeviceList);

View File

@ -17,6 +17,7 @@ import java.util.LinkedList;
import java.util.List;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -24,23 +25,38 @@ public class AuthHelper {
public static final String VALID_NUMBER = "+14150000000";
public static final String VALID_PASSWORD = "foo";
public static final String VALID_NUMBER_TWO = "+14151111111";
public static final String VALID_PASSWORD_TWO = "baz";
public static final String INVVALID_NUMBER = "+14151111111";
public static final String INVALID_PASSWORD = "bar";
public static AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class );
public static Account VALID_ACCOUNT = mock(Account.class );
public static Account VALID_ACCOUNT_TWO = mock(Account.class);
public static Device VALID_DEVICE = mock(Device.class );
public static AuthenticationCredentials VALID_CREDENTIALS = mock(AuthenticationCredentials.class);
public static Device VALID_DEVICE_TWO = mock(Device.class);
private static AuthenticationCredentials VALID_CREDENTIALS = mock(AuthenticationCredentials.class);
private static AuthenticationCredentials VALID_CREDENTIALS_TWO = mock(AuthenticationCredentials.class);
public static MultiBasicAuthProvider<FederatedPeer, Account> getAuthenticator() {
when(VALID_CREDENTIALS.verify("foo")).thenReturn(true);
when(VALID_CREDENTIALS_TWO.verify("baz")).thenReturn(true);
when(VALID_DEVICE.getAuthenticationCredentials()).thenReturn(VALID_CREDENTIALS);
when(VALID_DEVICE_TWO.getAuthenticationCredentials()).thenReturn(VALID_CREDENTIALS_TWO);
when(VALID_DEVICE.getId()).thenReturn(1L);
when(VALID_DEVICE_TWO.getId()).thenReturn(1L);
when(VALID_ACCOUNT.getDevice(anyLong())).thenReturn(Optional.of(VALID_DEVICE));
when(VALID_ACCOUNT_TWO.getDevice(eq(1L))).thenReturn(Optional.of(VALID_DEVICE_TWO));
when(VALID_ACCOUNT_TWO.getActiveDeviceCount()).thenReturn(3);
when(VALID_ACCOUNT.getNumber()).thenReturn(VALID_NUMBER);
when(VALID_ACCOUNT_TWO.getNumber()).thenReturn(VALID_NUMBER_TWO);
when(VALID_ACCOUNT.getAuthenticatedDevice()).thenReturn(Optional.of(VALID_DEVICE));
when(VALID_ACCOUNT_TWO.getAuthenticatedDevice()).thenReturn(Optional.of(VALID_DEVICE_TWO));
when(VALID_ACCOUNT.getRelay()).thenReturn(Optional.<String>absent());
when(VALID_ACCOUNT_TWO.getRelay()).thenReturn(Optional.<String>absent());
when(ACCOUNTS_MANAGER.get(VALID_NUMBER)).thenReturn(Optional.of(VALID_ACCOUNT));
when(ACCOUNTS_MANAGER.get(VALID_NUMBER_TWO)).thenReturn(Optional.of(VALID_ACCOUNT_TWO));
List<FederatedPeer> peer = new LinkedList<FederatedPeer>() {{
add(new FederatedPeer("cyanogen", "https://foo", "foofoo", "bazzzzz"));