/*
* DAVServlet.java
*
* Version: $Revision: 3705 $
*
* Date: $Date: 2009-04-11 17:02:24 +0000 (Sat, 11 Apr 2009) $
*
* Copyright (c) 2002-2007, Hewlett-Packard Company and Massachusetts
* Institute of Technology. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* - Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* - Neither the name of the Hewlett-Packard Company nor the name of the
* Massachusetts Institute of Technology nor the names of their
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
* OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
* TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
* DAMAGE.
*/
package org.dspace.app.dav;
import java.io.IOException;
import java.net.URLDecoder;
import java.sql.SQLException;
import java.util.Date;
import java.util.StringTokenizer;
import javax.servlet.GenericServlet;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.codec.binary.Base64;
import org.apache.log4j.Logger;
import org.dspace.authenticate.AuthenticationManager;
import org.dspace.authenticate.AuthenticationMethod;
import org.dspace.authorize.AuthorizeException;
import org.dspace.core.ConfigurationManager;
import org.dspace.core.Context;
import org.dspace.core.LogManager;
import org.dspace.core.Utils;
import org.dspace.eperson.EPerson;
/**
* Servlet implementing WebDAV server for DSpace.
* <P>
*
* @author Larry Stone
* @version $Revision: 3705 $
*/
public class DAVServlet extends HttpServlet
{
/** log4j category. */
private static Logger log = Logger.getLogger(DAVServlet.class);
/** Names of DAV HTTP extension methods. */
private static final String METHOD_PROPFIND = "PROPFIND";
/** The Constant METHOD_PROPPATCH. */
private static final String METHOD_PROPPATCH = "PROPPATCH";
/** The Constant METHOD_MKCOL. */
private static final String METHOD_MKCOL = "MKCOL";
/** The Constant METHOD_COPY. */
private static final String METHOD_COPY = "COPY";
/** The Constant METHOD_MOVE. */
private static final String METHOD_MOVE = "MOVE";
/** The Constant METHOD_LOCK. */
private static final String METHOD_LOCK = "LOCK";
/** The Constant METHOD_UNLOCK. */
private static final String METHOD_UNLOCK = "UNLOCK";
/** The Constant METHOD_DELETE. */
private static final String METHOD_DELETE = "DELETE";
/** Method names of standard HTTP methods; we have to override HttpServlet fields, because they are private (ugh). */
private static final String METHOD_GET = "GET";
/** The Constant METHOD_PUT. */
private static final String METHOD_PUT = "PUT";
/** The Constant METHOD_OPTIONS. */
private static final String METHOD_OPTIONS = "OPTIONS";
/** Switch to allow anonymous (unauthenticated) access to DAV resources. If true, client doesn't have to authenticate, false they do. */
private static boolean allowAnonymousAccess = ConfigurationManager
.getBooleanProperty("dav.access.anonymous");
/** Guess at longest status text the servlet container will tolerate; Tomcat 5.0 handles this, but dies on longer messages. */
private final static int HTTP_STATUS_MESSAGE_MAX = 1000;
/** A random secret to embed in cookies, generated fresh at every startup:. */
private static final String cookieSecret = Utils.generateHexKey();
// name of our HTTP cookie.
/** The Constant COOKIE_NAME. */
private static final String COOKIE_NAME = "DSpaceDavAuth";
// sell-by time (shelf life) for cookies, in milliseconds: 1/2 hour
/** The Constant COOKIE_SELL_BY. */
private static final long COOKIE_SELL_BY = 30 * 60 * 1000;
// 'C' is for cookie..
/**
* Gimme cookie.
*
* @param request the request
*
* @return the cookie
*/
private static Cookie gimmeCookie(HttpServletRequest request)
{
Cookie cookies[] = request.getCookies();
if (cookies != null)
for (Cookie element : cookies)
{
if (element.getName().equals(COOKIE_NAME))
{
return element;
}
}
return null;
}
/**
* Get Session Cookie.
* <p>
* DAVServlet rolls its own session cookie because the Servlet container's
* session <em>cannot</em> be constrained to use ONLY cookies and NOT
* URL-rewriting, and the latter would break the DAV protocol so we cannot
* use it. Since we really only need to cache the authenticated EPerson (an
* integer ID) anyway, it's easy enough so simply stuff that into a cookie.
* <p>
* Cookie format is: <br>
* {timestamp}!{epersonID}!{client-IP}!{MAC} <br>
* where timestamp and eperson are integers; client IP is dotted IP
* notation, and MAC is the hex MD5 of the preceding fields plus the
* "cookieSecret" string. The MAC ensures that the cookie was issued by this
* servlet.
* <p>
* Look for authentication cookie and try to get a previously-authenticated
* EPerson from it if found. Also check the timestamp to be sure the cookie
* isn't "stale".
* <p>
* NOTE This is also used by the SOAP servlet.
* <p>
*
* @param context -
* set user in this context
* @param request -
* HTTP request.
*
* @return true when a fresh cookie yields a valid eperson.
*
* @throws SQLException the SQL exception
*/
protected static boolean getAuthFromCookie(Context context,
HttpServletRequest request) throws SQLException
{
Cookie cookie = gimmeCookie(request);
if (cookie == null)
{
return false;
}
String crumb[] = cookie.getValue().split("\\!");
if (crumb.length != 4)
{
log
.warn("Got invalid cookie value = \"" + cookie.getValue()
+ "\"");
return false;
}
long timestamp = 0;
int epersonID = 0;
try
{
timestamp = Long.parseLong(crumb[0]);
epersonID = Integer.parseInt(crumb[1]);
}
catch (NumberFormatException e)
{
log.warn("Error groveling cookie, " + e.toString());
return false;
}
// check freshness
long now = new Date().getTime();
if (timestamp > now || (now - timestamp) > COOKIE_SELL_BY)
{
log.warn("Cookie is stale or has weird time, value = \""
+ cookie.getValue() + "\"");
return false;
}
// check IP address
if (!crumb[2].equals(request.getRemoteAddr()))
{
log.warn("Cookie fails IP Addr test, value = \""
+ cookie.getValue() + "\"");
return false;
}
// check MAC
String mac = Utils.getMD5(crumb[0] + "!" + crumb[1] + "!" + crumb[2]
+ "!" + cookieSecret);
if (!mac.equals(crumb[3]))
{
log.warn("Cookie fails MAC test, value = \"" + cookie.getValue()
+ "\"");
return false;
}
// looks like the browser reguritated a good one:
EPerson cuser = EPerson.find(context, epersonID);
if (cuser != null)
{
context.setCurrentUser(cuser);
log.debug("Got authenticated user from cookie, id=" + crumb[1]);
return true;
}
return false;
}
/**
* Set a new cookie -- only bother if there is no existing cookie or it's at
* least halfway stale, so you're not churning it.. When force is true,
* always set a fresh cookie. (e.g. after mac failure upon server restart,
* etc)
* <p>
*
* @param context -
* get user from context
* @param request the request
* @param response the response
* @param force the force
*/
protected static void putAuthCookie(Context context,
HttpServletRequest request, HttpServletResponse response,
boolean force)
{
Cookie cookie = gimmeCookie(request);
long now = new Date().getTime();
if (!force && cookie != null)
{
String crumb[] = cookie.getValue().split("\\!");
if (crumb.length == 4)
{
long timestamp = -1;
try
{
timestamp = Long.parseLong(crumb[0]);
}
catch (NumberFormatException e)
{
}
// check freshness - skip setting cookie if old one isn't stale
if (timestamp > 0 && (now - timestamp) < (COOKIE_SELL_BY / 2))
{
return;
}
}
}
EPerson user = context.getCurrentUser();
if (user == null)
{
return;
}
String value = String.valueOf(now) + "!" + String.valueOf(user.getID())
+ "!" + request.getRemoteAddr() + "!";
String mac = Utils.getMD5(value + cookieSecret);
cookie = new Cookie(COOKIE_NAME, value + mac);
cookie.setPath(request.getContextPath());
response.addCookie(cookie);
log.debug("Setting new cookie, value = \"" + value + mac + "\"");
}
/**
* Get authenticated user for this service. Returns null upon failure, with
* the implication that an error repsonse has already been "sent", so caller
* should not set anything else in servlet response.
*
* @param request the request
* @param response the response
* @param username the username
* @param password the password
*
* @return the context
*
* @throws IOException Signals that an I/O exception has occurred.
* @throws SQLException the SQL exception
*/
private static Context authenticate(HttpServletRequest request,
HttpServletResponse response, String username, String password)
throws IOException, SQLException
{
Context context = new Context();
if (getAuthFromCookie(context, request))
{
putAuthCookie(context, request, response, false);
return context;
}
// get username/password from Basic auth header if avail:
String cred = request.getHeader("Authorization");
if (cred != null && username == null && password == null)
{
log.info(LogManager.getHeader(context, "got creds", "Authorize: "
+ cred));
StringTokenizer ct = new StringTokenizer(cred);
// format: Basic {username:password in base64}
if (ct.nextToken().equalsIgnoreCase("Basic"))
{
String crud = ct.nextToken();
String dcrud = new String(Base64.decodeBase64(crud.getBytes()));
int colon = dcrud.indexOf(":");
if (colon > 0)
{
username = URLDecode(dcrud.substring(0, colon));
password = URLDecode(dcrud.substring(colon + 1));
log
.info(LogManager.getHeader(context, "auth",
"Got username=\"" + username
+ "\" out of \"" + crud + "\"."));
}
}
}
if (AuthenticationManager.authenticate(context, username, password,
null, request) == AuthenticationMethod.SUCCESS)
{
log.info(LogManager.getHeader(context, "auth",
"Authentication returned SUCCESS, eperson="
+ context.getCurrentUser().getEmail()));
}
else
{
if (username == null)
{
log.info(LogManager.getHeader(context, "auth",
"No credentials, so sending WWW-Authenticate header."));
}
else
{
log.warn(LogManager.getHeader(context, "auth",
"Authentication FAILED, cred=" + cred));
}
// ...EXCEPT if dav.access.anonymous is true in config:
if (!allowAnonymousAccess)
{
if (response != null)
{
response.setHeader("WWW-Authenticate",
"Basic realm=\"dspace\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
return null;
}
}
// Set any special groups - invoke the authentication mgr.
int[] groupIDs = AuthenticationManager.getSpecialGroups(context,
request);
for (int element : groupIDs)
{
context.setSpecialGroup(element);
log.debug("Adding Special Group id=" + String.valueOf(element));
}
putAuthCookie(context, request, response, true);
return context;
}
/**
* Return portion of URI path relevant to the DAV resource. We go through
* the extra pain of chopping up getRequestURI() because it is NOT
* URL-decoded by the Servlet container, while unfortunately getPathInfo()
* IS pre-decoded, leaving a redudndant "/" (and who knows what else) in the
* handle. Since the "handle" may not even be a CNRI Handle, we don't want
* to assume it even has a "/" (escaped or not).
* <p>
* Finally, search for doubled-up '/' separators and coalesce them.
*
* @param request the request
*
* @return String of undecoded path NOT starting with '/'.
*/
private static String getDavResourcePath(HttpServletRequest request)
{
String path = request.getRequestURI();
String ppath = path.substring(request.getContextPath().length());
String scriptName = request.getServletPath();
if (ppath.startsWith(scriptName))
{
ppath = ppath.substring(scriptName.length());
}
// log.debug("Got DAV URI: BEFORE // FIXUP: PATH_INFO=\"" + ppath+"\"");
// turn all double '/' ("//") in URI into single '/'
StringBuffer sb = new StringBuffer(ppath);
int i = ppath.length() - 2;
if (i > 0)
{
while ((i = ppath.lastIndexOf("//", i)) > -1)
{
sb.deleteCharAt(i + 1);
--i;
}
}
// remove leading '/'
if (sb.length() > 0 && sb.charAt(0) == '/')
{
sb.deleteCharAt(0);
}
ppath = sb.toString();
log.debug("Got DAV URI: PATH_INFO=\"" + ppath + "\"");
return ppath;
}
/**
* override service() to add DAV methods.
*
* @param request the request
* @param response the response
*
* @throws ServletException the servlet exception
* @throws IOException Signals that an I/O exception has occurred.
*/
@Override
protected void service(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException
{
String method = request.getMethod();
// no authentication needed for OPTIONS
if (method.equals(METHOD_OPTIONS))
{
doOptions(request, response);
}
else if (!serviceInternal(method, request, response))
{
super.service(request, response);
}
}
/**
* truncate string to max length for HTTP status message.
*
* @param msg the msg
*
* @return the string
*/
private static String truncateForStatus(String msg)
{
return (msg.length() > HTTP_STATUS_MESSAGE_MAX) ? msg.substring(0,
HTTP_STATUS_MESSAGE_MAX)
+ "... [Message truncated, see logs for details.]" : msg;
}
/**
* Pass this request along to the appropriate resource and method. Includes
* authentication, where needed. Return true if we handle this request,
* false otherwise. True means response has been "sent", false not.
*
* @param method the method
* @param request the request
* @param response the response
*
* @return true, if service internal
* @throws IOException Signals that an I/O exception has occurred.
*/
protected static boolean serviceInternal(String method,
HttpServletRequest request, HttpServletResponse response)
throws IOException
{
// Fake new DAV methods not understood by the Apache Servlet base class
// (returns HTTP/500 when it sees unrecognised method)
// The way it is faked is by submitting "delete=true" in the PUT URL's
// query parameters (for a delete)
// The way it is faked is by submitting "mkcol=true" in the PUT URL's
// query parameters (for a mk-collection)
if (method.equals(METHOD_PUT)
&& request.getQueryString().indexOf("delete=true") >= 0)
{
method = METHOD_DELETE;
}
if (method.equals(METHOD_PUT)
&& request.getQueryString().indexOf("mkcol=true") >= 0)
{
method = METHOD_MKCOL;
}
// if not a DAV method (i.e. POST), defer to superclass.
if (!(method.equals(METHOD_PROPFIND) || method.equals(METHOD_PROPPATCH)
|| method.equals(METHOD_MKCOL) || method.equals(METHOD_COPY)
|| method.equals(METHOD_MOVE) || method.equals(METHOD_DELETE)
|| method.equals(METHOD_GET) || method.equals(METHOD_PUT)))
{
return false;
}
// set all incoming encoding to UTF-8
request.setCharacterEncoding("UTF-8");
String pathElt[] = getDavResourcePath(request).split("/");
Context context = null;
try
{
// this sends a response on failure, unless it throws.
context = authenticate(request, response, null, null);
if (context == null)
{
return true;
}
// Note: findResource sends error response if it fails.
DAVResource resource = DAVResource.findResource(context, request,
response, pathElt);
if (resource != null)
{
if (method.equals(METHOD_PROPFIND))
{
resource.propfind();
}
else if (method.equals(METHOD_PROPPATCH))
{
resource.proppatch();
}
else if (method.equals(METHOD_COPY))
{
resource.copy();
}
else if (method.equals(METHOD_DELETE))
{
resource.delete();
}
else if (method.equals(METHOD_MKCOL))
{
resource.mkcol();
}
else if (method.equals(METHOD_GET))
{
resource.get();
}
else if (method.equals(METHOD_PUT))
{
resource.put();
}
else
{
response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
}
context.complete();
context = null;
}
}
catch (SQLException e)
{
log.error(e.toString(),e);
response
.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
truncateForStatus("Database access error: "
+ e.toString()));
}
catch (AuthorizeException e)
{
if(log.isDebugEnabled())
{
log.debug(e.toString(),e);
}
else
{
log.info(e.toString());
}
response.sendError(HttpServletResponse.SC_FORBIDDEN,
truncateForStatus("Access denied: " + e.toString()));
}
catch (DAVStatusException e)
{
log.error(e.toString(),e);
response.sendError(e.getStatus(), truncateForStatus(e
.getMessage()));
}
catch (IOException e)
{
log.error(e.toString(),e);
response
.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
truncateForStatus("IO Error: "
+ e.toString()));
}
catch (Exception e)
{
log.error(e.toString(),e);
response
.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
truncateForStatus("IO Error: "
+ e.toString()));
}
finally
{
// Abort the context if it's still valid
if (context != null && context.isValid())
{
context.abort();
}
}
return true;
}
/**
* Handler for HTTP OPTIONS method. Same for all resources under the WeDAV
* root. Add DAV methods so client knows we handle DAV.
*
* @param request the request
* @param response the response
*
* @throws ServletException the servlet exception
* @throws IOException Signals that an I/O exception has occurred.
*/
@Override
protected void doOptions(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException
{
// we only support minimal DAV
response.addHeader("DAV", "1");
response.addHeader("Allow",
"GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, "
+ "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE");
}
/**
* Sugar-coating for URLDecoder.decode, used all over.
*
* @param in the in
*
* @return the string
*/
protected static String URLDecode(String in)
{
try
{
return URLDecoder.decode(in, "UTF-8");
}
catch (java.io.UnsupportedEncodingException e)
{
return "";
}
}
// last servlet instance when put into service, set by init()
/** The servlet instance. */
private static GenericServlet servletInstance = null;
/* (non-Javadoc)
* @see javax.servlet.GenericServlet#init(javax.servlet.ServletConfig)
*/
@Override
public void init(ServletConfig sc) throws ServletException
{
super.init(sc);
servletInstance = this;
}
/**
* Gets the servlet instance.
*
* @return the servlet instance
*/
public static GenericServlet getServletInstance()
{
return servletInstance;
}
}