Create our Own Cookie

Instead of using App Engine sessions, create our own cookie
instead. This cookie contains all of the session state itself, encrypted
and mac’d so that the end-user cannot tamper with it.

The cookie itself is constructed using Google’s Protobufs code and
encrypted with the Keyczar library

Change-Id: Icde67c34bd3828a488876f6a1c0800fb86d7e7b2
parent 6f9b348a
......@@ -39,7 +39,7 @@
</condition>
<target name="all"
depends="AiServerLib,AiClientLib,AiRebindLib,YaClientApp,Keystore,WarLibs">
depends="AiServerLib,AiClientLib,AiRebindLib,YaClientApp,Keystore,WarLibs,InstallAuthKey">
</target>
<target name="tests"
......@@ -190,6 +190,8 @@
<!-- gwt libs -->
<copy todir="${build.war.dir}/WEB-INF/lib" file="${gwt.sdk}/gwt-servlet.jar" />
<copy todir="${build.war.dir}/WEB-INF/lib" file="${gwt.sdk}/gwt-servlet-deps.jar" />
<!-- Protocol Buffers -->
<copy todir="${build.war.dir}/WEB-INF/lib" file="${lib.dir}/protobuf/protobuf-2.6.1.jar" />
<!-- Add any additional server libs that need to be copied -->
<copy todir="${build.war.dir}/WEB-INF/lib" flatten="true">
<fileset dir="${appengine.sdk}/lib/user" includes="**/*.jar"/>
......@@ -228,6 +230,55 @@
<copy todir="${build.war.dir}/WEB-INF/lib" file="${local.build.dir}/AiServerLib.jar"/>
</target>
<!-- =====================================================================
Checks to see if we have our own authkey ZIP file.
===================================================================== -->
<target name="CheckAuthKey"
depends="init">
<available file="${user.home}/.appinventor/authkey.zip"
property="authkey.exists" />
</target>
<!-- =====================================================================
Makes the authkey file if it doesn't exist. This target needs
to be called explicitly. We do not run automatically so we
cannot accidentally over-write a valid existing file.
===================================================================== -->
<target name="MakeAuthKey"
depends="init,CheckAuthKey" unless="${authkey.exists}">
<tempfile prefix="mkkey" property="tmp.dir" destdir="${java.io.tmpdir}"/>
<mkdir dir="${tmp.dir}/authkey"/>
<java failonerror="true" fork="true"
jar="${lib.dir}/keyczar/KeyczarTool.jar">
<arg line="create --location=${tmp.dir}/authkey
--purpose=crypt"/>
</java>
<java failonerror="true" fork="true"
jar="${lib.dir}/keyczar/KeyczarTool.jar">
<arg line="addkey --location=${tmp.dir}/authkey"/>
</java>
<java failonerror="true" fork="true"
jar="${lib.dir}/keyczar/KeyczarTool.jar">
<arg line="promote --location=${tmp.dir}/authkey
--version=1"/>
</java>
<zip destfile="${user.home}/.appinventor/authkey.zip"
basedir="${tmp.dir}" includes="authkey/**" />
<delete file="${tmp.dir}/authkey/meta"/>
<delete file="${tmp.dir}/authkey/1"/>
</target>
<!-- =====================================================================
Install the Authkey into the correct location
===================================================================== -->
<target name="InstallAuthKey"
depends="init,CheckAuthKey,WarLibs">
<fail message="You Must Create an Auth Key see misc/docs/authkey.md"
unless="${authkey.exists}" />
<unzip dest="${build.war.dir}/WEB-INF"
src="${user.home}/.appinventor/authkey.zip"/>
</target>
<!-- =====================================================================
AiServerLibTests: build and run the AiServerLib tests and generate the output results
===================================================================== -->
......@@ -252,6 +303,7 @@
<pathelement location="${lib.dir}/powermock/powermock-easymock-1.4.10-full.jar" />
<pathelement location="${lib.dir}/responder-iq/responderiq-test.jar" />
<pathelement location="${lib.dir}/gcs/appengine-gcs-client-0.4.3.jar" />
<pathelement location="${lib.dir}/protobuf/protobuf-2.6.1.jar" />
<pathelement location="${gwt.sdk}/gwt-servlet.jar" />
<pathelement location="${gwt.sdk}/gwt-user.jar"/>
<pathelement location="${appengine.sdk}/lib/impl/appengine-api.jar"/>
......
......@@ -9,9 +9,12 @@ import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpSession;
import com.google.appinventor.server.flags.Flag;
import com.google.appinventor.server.OdeAuthFilter;
import com.google.appinventor.server.storage.StorageIo;
import com.google.appinventor.server.storage.StorageIoInstanceHolder;
import com.google.appinventor.shared.rpc.AdminInterfaceException;
......@@ -32,11 +35,6 @@ public class AdminInfoServiceImpl extends OdeRemoteServiceServlet implements Adm
// Storage of user settings
private final transient StorageIo storageIo = StorageIoInstanceHolder.INSTANCE;
// Access the higher level session object
// Note: If we want to do this from any other service, we should move this
// to OdeRemoveServiceServlet (our parent class)
protected final LocalSession localSession = LocalSession.getInstance();
/**
* Returns a list of AdminUsers, up to 20, based on the starting
* point.
......@@ -77,13 +75,25 @@ public class AdminInfoServiceImpl extends OdeRemoteServiceServlet implements Adm
if (!userInfoProvider.getIsAdmin()) {
throw new IllegalArgumentException("Unauthorized.");
}
// BEWARE THIS IS A HACK
// We are going to switch users and set the readOnly flag
// When this call returns we depend on the client side doing a complete
// reload. It will then get a new User object based on these updated
// session fields.
HttpSession session = localSession.getSession();
session.setAttribute("userid", user.getId());
session.setAttribute("readonly", true);
// OLD CODE
// HttpSession session = localSession.getSession();
// session.setAttribute("userid", user.getId());
// session.setAttribute("readonly", true);
OdeAuthFilter.UserInfo nuser = new OdeAuthFilter.UserInfo(user.getId(),
false, "en");
nuser.setReadOnly(true);
String newCookie = nuser.buildCookie(false);
Cookie cook = new Cookie("AppInventor", newCookie);
cook.setPath("/");
getThreadLocalResponse().addCookie(cook);
}
}
// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2009-2011 Google, All Rights reserved
// Copyright 2011-2012 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.server;
import javax.servlet.http.HttpSession;
/**
* A Singleton providing access to the Java HttpSession Object
* This permits us to set fields in the session from the AdminInfoService
* (and others if the need arises).
*
* @author jis@mit.edu (Jeffrey I. Schiller)
*/
public class LocalSession {
private ThreadLocal<HttpSession> session = new ThreadLocal<HttpSession>();
/**
* Returns the singleton LocalSession instance.
*
* @return localSession instance
*/
public static LocalSession getInstance() {
return LocalSessionInstanceHolder.INSTANCE;
}
private static class LocalSessionInstanceHolder {
private LocalSessionInstanceHolder() {} // not to be instantiated
private static final LocalSession INSTANCE = new LocalSession();
}
public void set(HttpSession session) {
this.session.set(session);
}
public HttpSession getSession() {
return session.get();
}
}
......@@ -7,10 +7,12 @@
package com.google.appinventor.server;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.appinventor.server.flags.Flag;
import java.io.IOException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
......@@ -25,7 +27,19 @@ public class LogoutServlet extends OdeServlet {
@Override
public void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
req.getSession().invalidate();
// req.getSession().invalidate();
Cookie cookie = new Cookie("AppInventor", null);
cookie.setPath("/");
cookie.setMaxAge(0); // This should cause it to be tossed immediately
res.addCookie(cookie);
// The code below is how you logout of Google. We have commented it out
// here because in LoginServlet.java we are now destroying the ACSID Cookie
// which effectively logs you out from Google's point of view, without effecting
// other Google Systems that the user might be using.
// Note: The code below will logout you out of ALL Google services
// (which can be pretty annoying
if (useGoogle.get() == true) {
res.sendRedirect(UserServiceFactory.getUserService().createLogoutURL("/"));
res.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
......
......@@ -6,18 +6,24 @@
package com.google.appinventor.server;
import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.appinventor.server.cookieauth.CookieAuth;
import java.io.Serializable;
import com.google.appinventor.server.flags.Flag;
import com.google.appinventor.server.storage.StorageIo;
import com.google.appinventor.server.storage.StorageIoInstanceHolder;
import com.google.appinventor.shared.rpc.ServerLayout;
import com.google.appinventor.shared.rpc.user.User;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.logging.Logger;
import java.util.logging.Level;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
......@@ -25,9 +31,16 @@ import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.keyczar.Crypter;
import org.keyczar.exceptions.KeyczarException;
import org.keyczar.util.Base64Coder;
/**
* An authentication filter that uses Google Accounts for logged-in users.
*
......@@ -40,15 +53,17 @@ public class OdeAuthFilter implements Filter {
private static final Logger LOG = Logger.getLogger(OdeAuthFilter.class.getName());
private final StorageIo storageIo = StorageIoInstanceHolder.INSTANCE;
private static Crypter crypter = null; // accessed through getCrypter only
private static final Object crypterSync = new Object();
private static final UserService userService = UserServiceFactory.getUserService();
private final StorageIo storageIo = StorageIoInstanceHolder.INSTANCE;
// Whether this server should use a whitelist to determine who can
// access it. Value is specified in the <system-properties> section
// of appengine-web.xml.
@VisibleForTesting
static final Flag<Boolean> useWhitelist = Flag.createFlag("use.whitelist", false);
static final Flag<String> sessionKeyFile = Flag.createFlag("session.keyfile", "WEB-INF/authkey");
private final LocalUser localUser = LocalUser.getInstance();
......@@ -67,36 +82,46 @@ public class OdeAuthFilter implements Filter {
final HttpServletResponse httpResponse = (HttpServletResponse) response;
// Use Local Authentication
String userid = (String) httpRequest.getSession().getAttribute("userid");
Object isReadOnlyObject = httpRequest.getSession().getAttribute("readonly");
boolean isReadOnly = false;
if (isReadOnlyObject != null) {
isReadOnly = (boolean) isReadOnlyObject;
}
LOG.info("isReadOnly = " + isReadOnly);
if (userid == null) { // Invalid Login
LOG.info("userid is null on login.");
// String userid = (String) httpRequest.getSession().getAttribute("userid");
// Object isReadOnlyObject = httpRequest.getSession().getAttribute("readonly");
// boolean isReadOnly = false;
// if (isReadOnlyObject != null) {
// isReadOnly = (boolean) isReadOnlyObject;
// }
// LOG.info("isReadOnly = " + isReadOnly);
// if (userid == null) { // Invalid Login
// LOG.info("userid is null on login.");
// httpResponse.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
// return;
// }
// Use Local Authentication
UserInfo userInfo = getUserInfo(httpRequest);
if (userInfo == null) { // Invalid Login
LOG.info("uinfo is null on login.");
httpResponse.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
return;
}
boolean isAdmin = false;
Object oIsAdmin = httpRequest.getSession().getAttribute("isadmin");
if (oIsAdmin != null) {
isAdmin = (boolean) oIsAdmin;
}
doMyFilter(userid, isAdmin, isReadOnly, httpRequest, httpResponse, chain);
String userId = userInfo.userId;
boolean isAdmin = userInfo.isAdmin;
boolean isReadOnly = userInfo.isReadOnly;
// Object oIsAdmin = httpRequest.getSession().getAttribute("isadmin");
// if (oIsAdmin != null) {
// isAdmin = (boolean) oIsAdmin;
// }
doMyFilter(userInfo, isAdmin, isReadOnly, httpRequest, httpResponse, chain);
}
@VisibleForTesting
void doMyFilter(String userid, boolean isAdmin, boolean isReadOnly,
void doMyFilter(UserInfo userInfo, boolean isAdmin, boolean isReadOnly,
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// Setup the user object for OdeRemoteServiceServlet
setUserFromUserId(userid, isAdmin, isReadOnly);
// Setup the session object for AdminInfoService
LocalSession.getInstance().set(request.getSession());
setUserFromUserId(userInfo.userId, isAdmin, isReadOnly);
// If using local login, we *must* have an email address because that is how we
// find the UserData object.
......@@ -123,6 +148,14 @@ public class OdeAuthFilter implements Filter {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
String newCookie = userInfo.buildCookie(true);
if (newCookie != null) { // If we get a value here, it is time to renew
// the Cookie
LOG.info("Renewing the authentication Cookie");
Cookie cook = new Cookie("AppInventor", newCookie);
cook.setPath("/");
response.addCookie(cook);
}
chain.doFilter(request, response);
} finally {
removeUser();
......@@ -187,4 +220,146 @@ public class OdeAuthFilter implements Filter {
@Override
public void init(FilterConfig arg0) throws ServletException {
}
// --- Support Routines for encrypted cookies --- //
public static class UserInfo implements Serializable {
String userId = "";
boolean isAdmin = false;
boolean isReadOnly = false;
String locale = "en";
long ts;
transient boolean modified = false;
public UserInfo() {
this.ts = System.currentTimeMillis();
}
public boolean getReadOnly() {
return this.isReadOnly;
}
public UserInfo(String userId, boolean isAdmin, String locale) {
this.userId = userId;
this.isAdmin = isAdmin;
this.locale = locale;
this.ts = System.currentTimeMillis();
}
public void setLocale(String locale) {
this.locale = locale;
modified = true;
}
public void setUserId(String userId) {
this.userId = userId;
modified = true;
}
public void setReadOnly(boolean value) {
this.isReadOnly = value;
modified = true;
}
public String getUserId() {
return userId;
}
public String getLocale() {
return locale;
}
public boolean getIsAdmin() {
return isAdmin;
}
public void setIsAdmin(boolean isAdmin) {
this.isAdmin = isAdmin;
modified = true;
}
public String buildCookie(boolean ifNeeded) {
try {
long offset = System.currentTimeMillis() - this.ts;
offset /= 1000;
if (offset > 4*3600) { // Renew if more then 4 hours old
modified = true;
ts = System.currentTimeMillis();
}
if (!ifNeeded || modified) {
Crypter crypter = getCrypter();
CookieAuth.cookie cookie = CookieAuth.cookie.newBuilder()
.setUuid(this.userId)
.setTs(this.ts)
.setIsAdmin(this.isAdmin)
.setLocale(this.locale)
.setIsReadOnly(this.isReadOnly).build();
return Base64Coder.encode(crypter.encrypt(cookie.toByteArray()));
} else {
return null;
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// Verify the timestamp
boolean isValid() {
long offset = System.currentTimeMillis() - this.ts;
offset /= 1000;
// Reject if older then 5 hours or if greater then 60 seconds
// in the future. We allow for 60 seconds in the future to deal
// with potential clock skew between app inventor servers
if (offset < -60 || offset > 3600*5) {
return false;
} else {
return true;
}
}
}
public static UserInfo getUserInfo(HttpServletRequest request) {
try {
Cookie [] cookies = request.getCookies();
if (cookies != null)
for (Cookie cookie : cookies) {
if ("AppInventor".equals(cookie.getName())) {
String rawData = cookie.getValue();
LOG.info("getUserInfo: rawCookie = " + rawData);
Crypter crypter = getCrypter();
CookieAuth.cookie cookieToken = CookieAuth.cookie.parseFrom(
crypter.decrypt(Base64Coder.decode(rawData)));
UserInfo uInfo = new UserInfo();
uInfo.userId = cookieToken.getUuid();
uInfo.ts = cookieToken.getTs();
uInfo.isAdmin = cookieToken.getIsAdmin();
uInfo.locale = cookieToken.getLocale();
uInfo.isReadOnly = cookieToken.getIsReadOnly();
if (uInfo.isValid()) {
return uInfo;
} else {
return null;
}
}
}
return null;
} catch (KeyczarException e) {
LOG.log(Level.SEVERE, "Error parsing provided cookie", e);
return null;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static Crypter getCrypter() throws KeyczarException {
synchronized(crypterSync) {
if (crypter != null) {
return crypter;
} else {
crypter = new Crypter(sessionKeyFile.get());
return crypter;
}
}
}
}
package cookieauth;
option java_package = "com.google.appinventor.server.cookieauth";
option java_outer_classname = "CookieAuth";
message cookie {
required string uuid = 1;
required uint64 ts = 2;
optional bool isAdmin = 3;
optional bool isReadOnly = 4;
optional string locale = 5;
optional uint64 oneProjectId = 6;
}
......@@ -32,7 +32,7 @@ import javax.servlet.http.HttpServletResponse;
* @author markf@google.com (Mark Friedman)
*/
@RunWith(PowerMockRunner.class)
@PrepareForTest({ LocalUser.class, OdeAuthFilter.class })
@PrepareForTest({ LocalUser.class, OdeAuthFilter.class, OdeAuthFilter.UserInfo.class })
public class OdeAuthFilterTest {
// If OdeAuthFilterTest (which uses PowerMock.mockStatic) extends LocalDatastoreTestCase, then
// it will probably fail with Ant version 1.8.2.
......@@ -42,6 +42,7 @@ public class OdeAuthFilterTest {
private HttpServletRequest mockServletRequest;
private HttpServletResponse mockServletResponse;
private LocalUser localUserMock;
private OdeAuthFilter.UserInfo localUserInfo;
@Before
public void setUp() throws Exception {
......@@ -52,6 +53,9 @@ public class OdeAuthFilterTest {
localUserMock.set(new User("1", "NonSuch", "NoName", null, 0, false, false, 0, null));
expectLastCall().times(1);
expect(localUserMock.getUserEmail()).andReturn("NonSuch").times(1);
localUserInfo = PowerMock.createMock(OdeAuthFilter.UserInfo.class);
expect(localUserInfo.buildCookie(false)).andReturn("NoCookie").anyTimes();
expect(localUserInfo.buildCookie(true)).andReturn("NoCookie").anyTimes();
mockFilterChain = PowerMock.createNiceMock(FilterChain.class);
mockServletRequest = PowerMock.createNiceMock(HttpServletRequest.class);
mockServletResponse = PowerMock.createNiceMock(HttpServletResponse.class);
......@@ -90,7 +94,7 @@ public class OdeAuthFilterTest {
}
};
myAuthFilter.doMyFilter("NonSuch", false, false, mockServletRequest, mockServletResponse, mockFilterChain);
myAuthFilter.doMyFilter(localUserInfo, false, false, mockServletRequest, mockServletResponse, mockFilterChain);
// isUserWhitelisted should not have been called.
assertEquals(0, isUserWhitelistedCounter.get());
......@@ -130,7 +134,7 @@ public class OdeAuthFilterTest {
}
};
myAuthFilter.doMyFilter("NonSuch", false, false, mockServletRequest, mockServletResponse, mockFilterChain);
myAuthFilter.doMyFilter(localUserInfo, false, false, mockServletRequest, mockServletResponse, mockFilterChain);
// isUserWhitelisted should have been called once.
assertEquals(1, isUserWhitelistedCounter.get());
......
......@@ -28,7 +28,8 @@
</static-files>
<!-- Permit sessions for location authentication -->
<sessions-enabled>true</sessions-enabled>
<!-- Not any more -->
<sessions-enabled>false</sessions-enabled>
<!-- Configuration and flags -->
<system-properties>
......
......@@ -2,18 +2,12 @@
<%@page contentType="text/html" pageEncoding="UTF-8"%>
<!doctype html>
<%
String error = (String) session.getAttribute("error");
if (error != null) {
session.removeAttribute("error");
}
String error = request.getParameter("error");
String useGoogleLabel = (String) request.getAttribute("useGoogleLabel");
String locale = request.getParameter("locale");
if (locale == null) {
locale = "en";
}
if("zh_CN".equals(locale) || "en".equals(locale)){
session.setAttribute("locale", locale);
}
%>
<html>
<head>
......
......@@ -21,6 +21,10 @@
<ant inheritAll="false" useNativeBasedir="true" dir="buildserver" target="PlayApp"/>
</target>
<target name="MakeAuthKey">
<ant inheritAll="false" useNativeBasedir="true" dir="appengine" target="MakeAuthKey"/>
</target>
<target name="comps">
<ant inheritAll="false" useNativeBasedir="true" dir="components"/>
<ant inheritAll="false" useNativeBasedir="true" dir="buildserver" target="installplay"/>
......
# The AuthKey System
## Introduction
This version of MIT App Inventor stores session state in a Cookie
named AppInventor. In many systems cookie values are database keys
which reference a database that contains the actual session
information. In an environment where here are multiple application
servers, this database must be accessible for all application servers.
However we take a different approach. The information we need to store
is very small. It mostly consists of the user’s unique user id. So
instead of storing this session state in a database, we place it
directly in the cookie.
However information stored directly in a cookie is subject to perusal
and modification by the end-user. To prevent this we encrypt and
compute a cryptographic checksum of the session state stored in the
cookie.
## The “authkey” file
The directory war/WEB-INF/authkey contains the encryption keys used to
encrypt/decrypt session cookies.
We could create this file each time we build the system. However that
would result in each build invalidating any cookies used by the
previous build. This would be a disaster for our production
environment and an annoyance for developers working with the local
development server.
Instead we keep a copy of the authkey keys in the file
$HOME/.appinventor/authkey.zip. During the build process its contents
are unzipped and placed in war/WEB-INF/authkey. If this file doesn’t
exist an error (pointing to this file) is generated by the build
system.
**To create the authkey.zip file you:**
ant MakeAuthKey
This will build the authkey.zip file and place it in your personal
storage.
\ No newline at end of file
#+TITLE: The AuthKey System
#+OPTIONS: num:nil toc:nil author:nil email:nil timestamp:nil creator:nil
* The AuthKey System
** Introduction
This version of MIT App Inventor stores session state in a Cookie
named AppInventor. In many systems cookie values are database keys
which reference a database that contains the actual session
information. In an environment where here are multiple application
servers, this database must be accessible for all application servers.
However we take a different approach. The information we need to store
is very small. It mostly consists of the user’s unique user id. So
instead of storing this session state in a database, we place it
directly in the cookie.
However information stored directly in a cookie is subject to perusal
and modification by the end-user. To prevent this we encrypt and
compute a cryptographic checksum of the session state stored in the
cookie.
** The “authkey” file
The directory war/WEB-INF/authkey contains the encryption keys used to
encrypt/decrypt session cookies.
We could create this file each time we build the system. However that
would result in each build invalidating any cookies used by the
previous build. This would be a disaster for our production
environment and an annoyance for developers working with the local
development server.
Instead we keep a copy of the authkey keys in the file
$HOME/.appinventor/authkey.zip. During the build process its contents
are unzipped and placed in war/WEB-INF/authkey. If this file doesn’t
exist an error (pointing to this file) is generated by the build
system.
*To create the authkey.zip file you:*
#+BEGIN_EXAMPLE
ant MakeAuthKey
#+END_EXAMPLE
This will build the authkey.zip file and place it in your personal
storage.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment