Add support for multiple devices. Chrome extension will send to all until it is modified to specify a device by ID or name.

This change also removes the legacy code that had security problems, i.e. accepting GET and requests without the XSRF header.

If a registration is reported as invalid ( i.e. application uninstalled ) - we clean up our database.
This commit is contained in:
costin
2010-08-30 20:25:41 +00:00
parent 9b3454aaef
commit f887a71bed
5 changed files with 248 additions and 78 deletions

View File

@@ -205,7 +205,7 @@ public class C2DMessaging {
log.warning("Got error response from Google datamessaging endpoint: " + err); log.warning("Got error response from Google datamessaging endpoint: " + err);
// No retry. // No retry.
// TODO(costin): show a nicer error to the user. // TODO(costin): show a nicer error to the user.
throw new IOException("Server error: " + err); throw new IOException(err);
} else { } else {
// 500 or unparseable response - server error, needs to retry // 500 or unparseable response - server error, needs to retry
log.warning("Invalid response from google " + responseLine + " " + log.warning("Invalid response from google " + responseLine + " " +

View File

@@ -16,6 +16,12 @@
package com.google.android.chrometophone.server; package com.google.android.chrometophone.server;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.jdo.PersistenceManager;
import javax.jdo.Query;
import javax.jdo.annotations.IdentityType; import javax.jdo.annotations.IdentityType;
import javax.jdo.annotations.PersistenceCapable; import javax.jdo.annotations.PersistenceCapable;
import javax.jdo.annotations.Persistent; import javax.jdo.annotations.Persistent;
@@ -23,8 +29,24 @@ import javax.jdo.annotations.PrimaryKey;
import com.google.appengine.api.datastore.Key; import com.google.appengine.api.datastore.Key;
/**
* Registration info.
*
* An account may be associated with multiple phones,
* and a phone may be associated with multiple accounts.
*
* registrations lists different phones registered to that account.
*/
@PersistenceCapable(identityType = IdentityType.APPLICATION) @PersistenceCapable(identityType = IdentityType.APPLICATION)
public class DeviceInfo { public class DeviceInfo {
/**
* User-email # device-id
*
* Device-id can be specified by device, default is hash of abs(registration
* id).
*
* user@example.com#1234
*/
@PrimaryKey @PrimaryKey
@Persistent @Persistent
private Key key; private Key key;
@@ -32,12 +54,47 @@ public class DeviceInfo {
@Persistent @Persistent
private String deviceRegistrationID; private String deviceRegistrationID;
/**
* Each device should provide a stable ID. It can be the
* hash of the first registration, the phone ID, etc.
* Using the name seems error-prone, users may use the default
* which may be the same in identical phones, they may change name, etc.
*/
@Persistent
private String id;
/**
* Current supported types:
* (default) - ac2dm, regular froyo+ devices using C2DM protocol
*
* New types may be defined - for example for sending to chrome.
*/
@Persistent
private String type;
/**
* Friendly name for the device. May be edited by the user.
*/
@Persistent
private String name;
/**
* For statistics - and to provide hints to the user.
*/
@Persistent
private Date registrationTimestamp;
@Persistent @Persistent
private Boolean debug; private Boolean debug;
public DeviceInfo(Key key, String deviceRegistrationID) { public DeviceInfo(Key key, String deviceRegistrationID) {
this.key = key; this.key = key;
this.deviceRegistrationID = deviceRegistrationID; this.deviceRegistrationID = deviceRegistrationID;
this.setRegistrationTimestamp(new Date()); // now
}
public DeviceInfo(Key key) {
this.key = key;
} }
public boolean getDebug() { public boolean getDebug() {
@@ -63,4 +120,55 @@ public class DeviceInfo {
public void setDeviceRegistrationID(String deviceRegistrationID) { public void setDeviceRegistrationID(String deviceRegistrationID) {
this.deviceRegistrationID = deviceRegistrationID; this.deviceRegistrationID = deviceRegistrationID;
} }
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
public void setType(String type) {
this.type = type;
}
public String getType() {
return type;
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setRegistrationTimestamp(Date registrationTimestamp) {
this.registrationTimestamp = registrationTimestamp;
}
public Date getRegistrationTimestamp() {
return registrationTimestamp;
}
/**
* Helper function - will query all registrations for a user.
*/
public static List<DeviceInfo> getDeviceInfoForUser(PersistenceManager pm, String user) {
Query query = pm.newQuery(DeviceInfo.class);
query.setFilter("key >= '" +
user + "' && key < '" + user + "$'");
List<DeviceInfo> qresult = (List<DeviceInfo>) query.execute();
// Copy to array - we need to close the query
List<DeviceInfo> result = new ArrayList<DeviceInfo>();
for (DeviceInfo di: qresult) {
result.add(di);
}
query.closeAll();
return result;
}
} }

View File

@@ -17,6 +17,8 @@
package com.google.android.chrometophone.server; package com.google.android.chrometophone.server;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import javax.jdo.PersistenceManager; import javax.jdo.PersistenceManager;
@@ -40,6 +42,8 @@ public class RegisterServlet extends HttpServlet {
private static final String OK_STATUS = "OK"; private static final String OK_STATUS = "OK";
private static final String ERROR_STATUS = "ERROR"; private static final String ERROR_STATUS = "ERROR";
private static int MAX_DEVICES = 3;
/** /**
* Get the user using the UserService. * Get the user using the UserService.
* *
@@ -74,25 +78,16 @@ public class RegisterServlet extends HttpServlet {
return user; return user;
} }
/**
* @deprecated will be removed in next rel.
*/
@Deprecated
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
doPost(req, resp);
}
@Override @Override
public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain"); resp.setContentType("text/plain");
// Basic XSRF protection // Basic XSRF protection
if (req.getHeader("X-Same-Domain") == null) { if (req.getHeader("X-Same-Domain") == null) {
// TODO: Enable at consumer launch log.warning("Blocked XSRF");
//resp.setStatus(400); resp.setStatus(400);
//resp.getWriter().println(ERROR_STATUS + " (Missing X-Same-Domain header)"); resp.getWriter().println(ERROR_STATUS + " (Missing X-Same-Domain header)");
//return; return;
} }
String deviceRegistrationID = req.getParameter("devregid"); String deviceRegistrationID = req.getParameter("devregid");
@@ -102,20 +97,64 @@ public class RegisterServlet extends HttpServlet {
return; return;
} }
String deviceName = req.getParameter("deviceName");
if (deviceName == null) {
deviceName = "Phone";
}
String deviceId = req.getParameter("deviceId");
if (deviceId == null) {
deviceId = Long.toHexString(Math.abs(deviceRegistrationID.hashCode()));
}
String deviceType = req.getParameter("deviceType");
if (deviceType == null) {
deviceType = "ac2dm";
}
User user = checkUser(req, resp, true); User user = checkUser(req, resp, true);
if (user != null) { if (user != null) {
Key key = KeyFactory.createKey(DeviceInfo.class.getSimpleName(), user.getEmail());
DeviceInfo device = new DeviceInfo(key, deviceRegistrationID);
// Context-shared PMF. // Context-shared PMF.
PersistenceManager pm = PersistenceManager pm =
C2DMessaging.getPMF(getServletContext()).getPersistenceManager(); C2DMessaging.getPMF(getServletContext()).getPersistenceManager();
try { try {
List<DeviceInfo> registrations = DeviceInfo.getDeviceInfoForUser(pm,
user.getEmail());
if (registrations.size() > MAX_DEVICES) {
// we could return an error - but user can't handle it yet.
// we can't let it grow out of bounds.
// TODO: we should also define a 'ping' message and expire/remove
// unused registrations
DeviceInfo oldest = registrations.get(0);
long oldestTime = oldest.getRegistrationTimestamp().getTime();
for (int i = 1; i < registrations.size(); i++) {
if (registrations.get(i).getRegistrationTimestamp().getTime() <
oldestTime) {
oldest = registrations.get(i);
oldestTime = oldest.getRegistrationTimestamp().getTime();
}
}
pm.deletePersistent(oldest);
}
// TODO: dup ? update
String id = Long.toHexString(Math.abs(deviceRegistrationID.hashCode()));
Key key = KeyFactory.createKey(DeviceInfo.class.getSimpleName(),
user.getEmail() + "#" + id);
DeviceInfo device = new DeviceInfo(key, deviceRegistrationID);
device.setId(deviceId);
device.setName(deviceName);
pm.makePersistent(device); pm.makePersistent(device);
resp.getWriter().println(OK_STATUS); resp.getWriter().println(OK_STATUS);
} catch (Exception e) { } catch (Exception e) {
resp.setStatus(500); resp.setStatus(500);
resp.getWriter().println(ERROR_STATUS + " (Error registering device)"); resp.getWriter().println(ERROR_STATUS + " (Error registering device)");
log.warning("Error registering device."); log.log(Level.WARNING, "Error registering device.", e);
} finally { } finally {
pm.close(); pm.close();
} }

View File

@@ -17,6 +17,7 @@
package com.google.android.chrometophone.server; package com.google.android.chrometophone.server;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.logging.Logger; import java.util.logging.Logger;
import javax.jdo.JDOObjectNotFoundException; import javax.jdo.JDOObjectNotFoundException;
@@ -39,11 +40,7 @@ public class SendServlet extends HttpServlet {
private static final String DEVICE_NOT_REGISTERED_STATUS = "DEVICE_NOT_REGISTERED"; private static final String DEVICE_NOT_REGISTERED_STATUS = "DEVICE_NOT_REGISTERED";
private static final String ERROR_STATUS = "ERROR"; private static final String ERROR_STATUS = "ERROR";
@Deprecated // GET not supported
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
doPost(req, resp);
}
@Override @Override
public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
@@ -80,59 +77,87 @@ public class SendServlet extends HttpServlet {
return; return;
} }
String deviceId = req.getParameter("deviceId");
String deviceName = req.getParameter("deviceName");
User user = RegisterServlet.checkUser(req, resp, false); User user = RegisterServlet.checkUser(req, resp, false);
if (user != null) { if (user != null) {
doSendToPhone(url, title, sel, user.getEmail(), resp); doSendToPhone(url, title, sel, user.getEmail(), deviceId, deviceName,
resp);
} else { } else {
resp.getWriter().println(LOGIN_REQUIRED_STATUS); resp.getWriter().println(LOGIN_REQUIRED_STATUS);
} }
} }
private boolean doSendToPhone(String url, String title, String sel, private boolean doSendToPhone(String url, String title, String sel,
String userAccount, HttpServletResponse resp) throws IOException { String userAccount, String deviceId,
// Get device info String deviceName, HttpServletResponse resp) throws IOException {
DeviceInfo deviceInfo = null;
// Shared PMF // ok = we sent to at least one phone.
PersistenceManager pm = boolean ok = false;
C2DMessaging.getPMF(getServletContext()).getPersistenceManager();
try {
Key key = KeyFactory.createKey(DeviceInfo.class.getSimpleName(), userAccount);
try {
deviceInfo = pm.getObjectById(DeviceInfo.class, key);
} catch (JDOObjectNotFoundException e) {
log.warning("Device not registered");
resp.getWriter().println(DEVICE_NOT_REGISTERED_STATUS);
return false;
}
} finally {
pm.close();
}
// Send push message to phone // Send push message to phone
C2DMessaging push = C2DMessaging.get(getServletContext()); C2DMessaging push = C2DMessaging.get(getServletContext());
boolean res = false; boolean res = false;
String collapseKey = "" + url.hashCode(); String collapseKey = "" + url.hashCode();
if (deviceInfo.getDebug()) {
PersistenceManager pm =
C2DMessaging.getPMF(getServletContext()).getPersistenceManager();
List<DeviceInfo> registrations = null;
try {
registrations = DeviceInfo.getDeviceInfoForUser(C2DMessaging.getPMF(getServletContext())
.getPersistenceManager(), userAccount);
} finally {
pm.close();
}
if (registrations.size() == 0) {
log.warning("Device not registered");
resp.getWriter().println(DEVICE_NOT_REGISTERED_STATUS);
return false;
}
for (DeviceInfo deviceInfo: registrations) {
if (deviceId != null && !deviceId.equals(deviceInfo.getId())) {
continue; // user-specified device
}
if (deviceName != null && !deviceName.equals(deviceInfo.getName())) {
continue; // user-specified device
}
// if name or value are null - they'll be skipped
try {
res = push.sendNoRetry(deviceInfo.getDeviceRegistrationID(), res = push.sendNoRetry(deviceInfo.getDeviceRegistrationID(),
collapseKey, collapseKey,
"url", url, "url", url,
"title", title, "title", title,
"sel", sel, "sel", sel,
"debug", "1"); "debug", deviceInfo.getDebug() ? "1" : null);
} else {
res = push.sendNoRetry(deviceInfo.getDeviceRegistrationID(),
collapseKey,
"url", url,
"title", title,
"sel", sel);
}
if (res) { if (res) {
log.info("Link sent to phone! collapse_key:" + collapseKey); log.info("Link sent to phone! collapse_key:" + collapseKey);
ok = true;
} else {
log.warning("Error: Unable to send link to phone.");
}
} catch (IOException ex) {
if ("NotRegistered".equals(ex.getMessage()) ||
"InvalidRegistration".equals(ex.getMessage())) {
// remove registrations, it no longer works
pm.deletePersistent(deviceInfo);
throw ex;
} else {
throw ex;
}
}
}
if (ok) {
resp.getWriter().println(OK_STATUS); resp.getWriter().println(OK_STATUS);
return true; return true;
} else { } else {
log.warning("Error: Unable to send link to phone.");
resp.setStatus(500); resp.setStatus(500);
resp.getWriter().println(ERROR_STATUS + " (Unable to send link)"); resp.getWriter().println(ERROR_STATUS + " (Unable to send link)");
return false; return false;

View File

@@ -17,6 +17,7 @@
package com.google.android.chrometophone.server; package com.google.android.chrometophone.server;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.logging.Logger; import java.util.logging.Logger;
import javax.jdo.JDOObjectNotFoundException; import javax.jdo.JDOObjectNotFoundException;
@@ -39,25 +40,15 @@ public class UnregisterServlet extends HttpServlet {
private static final String OK_STATUS = "OK"; private static final String OK_STATUS = "OK";
private static final String ERROR_STATUS = "ERROR"; private static final String ERROR_STATUS = "ERROR";
/**
* @deprecated Will be removed in next rel cycle.
*/
@Deprecated
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
doPost(req, resp);
}
@Override @Override
public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain"); resp.setContentType("text/plain");
// Basic XSRF protection // Basic XSRF protection
if (req.getHeader("X-Same-Domain") == null) { if (req.getHeader("X-Same-Domain") == null) {
// TODO: Enable at consumer launch resp.setStatus(400);
//resp.setStatus(400); resp.getWriter().println(ERROR_STATUS + " (Missing X-Same-Domain header)");
//resp.getWriter().println(ERROR_STATUS + " (Missing X-Same-Domain header)"); return;
//return;
} }
String deviceRegistrationID = req.getParameter("devregid"); String deviceRegistrationID = req.getParameter("devregid");
@@ -71,12 +62,19 @@ public class UnregisterServlet extends HttpServlet {
UserService userService = UserServiceFactory.getUserService(); UserService userService = UserServiceFactory.getUserService();
User user = userService.getCurrentUser(); User user = userService.getCurrentUser();
if (user != null) { if (user != null) {
Key key = KeyFactory.createKey(DeviceInfo.class.getSimpleName(), user.getEmail());
PersistenceManager pm = PersistenceManager pm =
C2DMessaging.getPMF(getServletContext()).getPersistenceManager(); C2DMessaging.getPMF(getServletContext()).getPersistenceManager();
try { try {
DeviceInfo device = pm.getObjectById(DeviceInfo.class, key); List<DeviceInfo> registrations = DeviceInfo.getDeviceInfoForUser(pm, user.getEmail());
pm.deletePersistent(device); for (int i = 0; i < registrations.size(); i++) {
DeviceInfo deviceInfo = registrations.get(i);
if (deviceInfo.getDeviceRegistrationID().equals(deviceRegistrationID)) {
pm.deletePersistent(deviceInfo);
registrations.remove(i);
break;
}
}
resp.getWriter().println(OK_STATUS); resp.getWriter().println(OK_STATUS);
} catch (JDOObjectNotFoundException e) { } catch (JDOObjectNotFoundException e) {
resp.setStatus(400); resp.setStatus(400);