// -*- 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 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;
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.
*
* @author markf@google.com (Mark Friedman)
*/
@SuppressWarnings({"ThrowableInstanceNeverThrown"})
public class OdeAuthFilter implements Filter {
public OdeAuthFilter() {}
private static final Logger LOG = Logger.getLogger(OdeAuthFilter.class.getName());
private static Crypter crypter = null; // accessed through getCrypter only
private static final Object crypterSync = new Object();
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");
static final Flag<Integer> idleTimeout = Flag.createFlag("session.idletimeout", 120);
static final Flag<Integer> renewTime = Flag.createFlag("session.renew", 30);
private final LocalUser localUser = LocalUser.getInstance();
private static final boolean DEBUG = Flag.createFlag("appinventor.debugging", false).get();
/**
* Filters using Google Accounts
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest && response instanceof HttpServletResponse)) {
throw new ServletException("Unsupported request type.");
}
final HttpServletRequest httpRequest = (HttpServletRequest) request;
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.");
// httpResponse.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
// return;
// }
// Use Local Authentication
UserInfo userInfo = getUserInfo(httpRequest);
if (userInfo == null) { // Invalid Login
if (DEBUG) {
LOG.info("uinfo is null on login.");
}
// If the URI starts with /ode, then we are being invoked through
// the App Inventor client. In that case we are in an XMLHttpRequest
// (aka ajax) so we cannot send a redirect to the login page
// instead we return SC_PRECONDITION_FAILED which tips off the
// client that it needs to reload itself to the login page.
String uri = httpRequest.getRequestURI();
if (DEBUG) {
LOG.info("Not Logged In: uri = " + uri);
}
if (uri.startsWith("/ode")) {
httpResponse.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
} else {
httpResponse.sendRedirect("/login?redirect=" + uri);
}
return;
}
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(UserInfo userInfo, boolean isAdmin, boolean isReadOnly,
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// Setup the user object for OdeRemoteServiceServlet
setUserFromUserId(userInfo.userId, isAdmin, isReadOnly);
// If using local login, we *must* have an email address because that is how we
// find the UserData object.
String lemail = localUser.getUserEmail();
if (lemail.equals("")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
try {
if (useWhitelist.get() && !isUserWhitelisted()) {
writeWhitelistErrorMessage(response);
// This indicates to the client side code that the user is not on the whitelist.
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return; // This blocks further processing of the request.
}
// If user hasn't accepted terms of service, redirect them,
// unless they're submitting the acceptance request.
if (!localUser.getUserTosAccepted() && !isReadOnly &&
!request.getRequestURI().endsWith(ServerLayout.ACCEPT_TOS_SERVLET)) {
// This indicates to the client side code that the user needs to accept
// the terms of service. We don't send the redirect here because
// it isn't understood properly by GWT RPC
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
if (DEBUG) {
LOG.info("Renewing the authentication Cookie");
}
Cookie cook = new Cookie("AppInventor", newCookie);
cook.setPath("/");
response.addCookie(cook);
}
chain.doFilter(request, response);
} finally {
removeUser();
}
}
@VisibleForTesting
boolean isUserWhitelisted() {
//return whitelist.isInWhitelist(localUser);
return storageIo.checkWhiteList(localUser.getUserEmail());
}
@VisibleForTesting
void writeWhitelistErrorMessage(HttpServletResponse response) throws IOException {
response.setContentType("text/plain; charset=utf-8");
PrintWriter out = response.getWriter();
out.print("You are attempting to connect to this App Inventor service with the login ID:\n\n" +
localUser.getUserEmail() + "\n\nThat ID has not been authorized to use this service. " +
"If you believe that you were in fact given authorization, you should contact the " +
"service operator.");
}
/*
* Sets the user for the current thread according to the given userId.
*
* <p>This method is called from {@link WebStartFileServlet} with the userId
* that was encrypted in the URL.
*/
void setUserFromUserId(String userId, boolean isAdmin, boolean isReadOnly) {
User user = storageIo.getUser(userId);
if (!user.getIsAdmin() && isAdmin) {
user.setIsAdmin(true); // If session says they are an admin (which is the case
// if they are a Google Account with Developer access
}
user.setReadOnly(isReadOnly);
localUser.set(user);
}
/*
* Clears the user for the current thread.
*
* <p>This method is called from {@link #doMyFilter} above.
*
* <p>This method is called from {@link WebStartFileServlet}, a non-filtered
* servlet.
*/
@VisibleForTesting
void removeUser() {
localUser.set(null);
}
/* (non-Javadoc)
* @see javax.servlet.Filter#destroy()
*/
@Override
public void destroy() {
}
/* (non-Javadoc)
* @see javax.servlet.Filter#init(javax.servlet.FilterConfig)
*/
@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;
long ts;
transient boolean modified = false;
public UserInfo() {
this.ts = System.currentTimeMillis();
}
public boolean getReadOnly() {
return this.isReadOnly;
}
public UserInfo(String userId, boolean isAdmin) {
this.userId = userId;
this.isAdmin = isAdmin;
this.ts = System.currentTimeMillis();
}
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 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 > (60*renewTime.get())) { // Renew if it is time
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)
.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 idleTimeout (minutes) 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 > (60*idleTimeout.get())) {
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();
if (DEBUG) {
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.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;
}
}
}
}