org.dspace.app.dav.DAVServlet.java Source code

Java tutorial

Introduction

Here is the source code for org.dspace.app.dav.DAVServlet.java

Source

/**
 * The contents of this file are subject to the license and copyright
 * detailed in the LICENSE and NOTICE files at the root of the source
 * tree and available online at
 *
 * http://www.dspace.org/license/
 */
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.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$
 */
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_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 static final 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();

        try {
            if (getAuthFromCookie(context, request)) {
                putAuthCookie(context, request, response, false);
                Context returnContext = context;
                ;
                context = null;
                return returnContext;
            }

            // 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 = decodeFromURL(dcrud.substring(0, colon));
                        password = decodeFromURL(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);

            Context returnContext = context;
            ;
            context = null;
            return returnContext;
        } finally {
            if (context != null)
                context.abort();
        }
    }

    /**
     * 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 decodeFromURL(String in) {
        try {
            return URLDecoder.decode(in, "UTF-8");
        } catch (java.io.UnsupportedEncodingException e) {
            return "";
        }
    }
}