com.sap.cloudlabs.connectivity.proxy.ProxyServlet.java Source code

Java tutorial

Introduction

Here is the source code for com.sap.cloudlabs.connectivity.proxy.ProxyServlet.java

Source

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the LICENSE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */
package com.sap.cloudlabs.connectivity.proxy;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Arrays;
import java.util.Collection;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.http.Header;
import org.apache.http.HeaderElement;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.DeflateDecompressingEntity;
import org.apache.http.client.entity.GzipDecompressingEntity;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.sap.cloudlabs.connectivity.proxy.SecurityHandler;
import com.sap.core.connectivity.api.DestinationException;
import com.sap.core.connectivity.api.DestinationFactory;
import com.sap.core.connectivity.api.http.HttpDestination;

/**
 * This servlet is used as connectivity proxy between a consuming agent, like a Web browser
 * application, and a backend service and can be seen as an add-on of the SAP HANA Cloud 
 * connectivity service. The backend service can either be an on-premise application  
 * which is accessed via SAP HANA Cloud connector, or an Internet-accessible service. For 
 * both cases, the servlet tries to access the remote service via a configured destination. 
 * The name of the destination has to be passed by the calling client of this servlet 
 * as part of the URL, following this pattern: 
 * <pre>
 *   /<context-path>/<servlet-path>/<destinationName>/<relative-path-to-backend-service>
 * </pre> 
 * <p>
 * Main purpose of the proxy servlet is to assure the same-origin-policy (SOP)
 * for JavaScript applications running in Web browsers.
 * 
 * @version 0.1
 */

/*
 * In case you want to manage servlet urlPatterns and security constraints 
 * with annotations you can replace web.xml file entries for urlPatterns and security constraints
 * <code><servlet-mapping>
 *         <servlet-name>ConnectivityProxy</servlet-name>
 *         <url-pattern>/proxy/yourDestinationName1/*</url-pattern>
 *         <url-pattern>/proxy/yourDestinationName2/*</url-pattern>
 *      </servlet-mapping>
 * </code>
 * with: 
 * <code>@WebServlet(name="ConnectivityProxy", urlPatterns={"/proxy/yourDestinationName1", "/proxy/yourDestinationName2"})
 * @ServletSecurity(@HttpConstraint(rolesAllowed = {"Administrator"}))
 * </code>
 */

public class ProxyServlet extends HttpServlet {
    private static final String ISO_8859_1 = "ISO-8859-1";

    private static final long serialVersionUID = 1L;

    /* headers which will be blocked from forwarding in backend request */
    private static String[] BLOCKED_REQUEST_HEADERS = { "host", "content-length", "SAP_SESSIONID_DT1_100",
            "MYSAPSSO2", "JSESSIONID" };

    /* buffer size for piping the content */
    private static final int IO_BUFFER_SIZE = 4 * 1024;

    private static final Logger LOGGER = LoggerFactory.getLogger(ProxyServlet.class);

    /*
     * In case you want to manage servlet resources  
     * with annotations you can replace web.xml  declaration
     * <code> <resource-ref>
     *   <res-ref-name>connectivity/DestinationFactory</res-ref-name>
     *   <res-type>com.sap.core.connectivity.api.DestinationFactory</res-type>
     *   </resource-ref> 
     * </code>
     * file with following annotations
     * <code>   @Resource com.sap.core.connectivity.api.DestinationFactory destinationFactory;</code>      
     * Then the destinationFactory declaration is obsolete, 
     * as well as the lookup in method <code>getDestination(String)</code>
     */
    private static DestinationFactory destinationFactory;

    private SecurityHandler securityHandler;

    /*
     * @see javax.servlet.GenericServlet#init(javax.servlet.ServletConfig)
     */
    public void init(ServletConfig servletConfig) throws ServletException {
        super.init(servletConfig);
        String securityHandlerName = servletConfig.getInitParameter("security.handler");

        if (securityHandlerName != null) {
            try {
                Class<?> clazz = Class.forName(securityHandlerName);

                if (SecurityHandler.class.isAssignableFrom(clazz)) {
                    securityHandler = (SecurityHandler) clazz.newInstance();
                } else {
                    LOGGER.debug("Provided security.handler " + securityHandlerName
                            + " is not an implementation of SecurityHandler class: ");
                }
                // no exception will be thrown as the proxy servlet can work without security handler implementation
            } catch (ClassNotFoundException e) {
                LOGGER.error("Provided security.handler " + securityHandlerName + " cannot be loaded");

            } catch (InstantiationException e) {
                LOGGER.error("Provided security.handler " + securityHandlerName + " cannot be instantioated");
            } catch (IllegalAccessException e) {
                LOGGER.error("Provided security.handler " + securityHandlerName + " cannot be accessed");
            }
        }
    }

    protected void service(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        LOGGER.debug(">>>>>>>>>>>> start request");

        // read destination and relative service path from URL
        String queryString = request.getQueryString();
        String destinationName = getDestinationFromUrl(request.getServletPath());
        String pathInfo = null;

        int contextPathLength = request.getContextPath().length();
        int servletPathLength = request.getServletPath().length();

        if (request.getRequestURI().endsWith(destinationName)) {
            pathInfo = "";
        } else {
            pathInfo = request.getRequestURI().substring(servletPathLength + contextPathLength);

        }
        String urlToService = getRelativePathFromUrl(pathInfo, queryString);

        // get the http client for the destination
        HttpDestination dest = getDestination(destinationName);
        HttpClient httpClient = null;
        try {
            httpClient = dest.createHttpClient();

            // create request to targeted backend service
            HttpRequestBase backendRequest = getBackendRequest(request, urlToService);

            // execute the backend request
            HttpResponse backendResponse = httpClient.execute(backendRequest);

            String rewriteUrl = getDestinationUrl(dest);
            String proxyUrl = getProxyUrl(request);

            // process response from backend request and pipe it to origin response of client
            processBackendResponse(request, response, backendResponse, proxyUrl, rewriteUrl);
        } catch (DestinationException e) {
            throw new ServletException(e);
        } finally {
            if (httpClient != null) {
                httpClient.getConnectionManager().shutdown();
            }

            LOGGER.debug(">>>>>>>>>>>> end request");
        }
    }

    /**
     * Returns the URL specified in the given destination. 
     */
    private String getDestinationUrl(HttpDestination destination) throws ServletException {
        try {
            String rewriteUrl = destination.getURI().toString();
            if (rewriteUrl.endsWith("/")) {
                rewriteUrl = rewriteUrl.substring(0, rewriteUrl.length() - 1);
            }
            return rewriteUrl;
        } catch (URISyntaxException e) {
            throw new ServletException(e);
        }
    }

    /**
     * Returns the URL to the proxy servlet and used destination. 
     */
    private String getProxyUrl(HttpServletRequest request) throws MalformedURLException {
        URL url = new URL(request.getRequestURL().toString());
        String proxyUrl = request.getScheme() + "://" + url.getAuthority() + request.getContextPath()
                + request.getServletPath();
        return proxyUrl;
    }

    /**
     * Process response received from backend service and copy it to origin response of 
     * client.
     * 
     * @param request
     *            origin request of this Web application
     * @param response
     *            origin response of this Web application; this is where the
     *            backend response is copied to
     * @param backendResponse
     *            the response of the backend service
     * @param proxyUrl
     *            the URL that should replace the <code>rewriteUrl</code>
     * @param rewriteUrl
     *            the URL that should be rewritten 
     */
    private void processBackendResponse(HttpServletRequest request, HttpServletResponse response,
            HttpResponse backendResponse, String proxyUrl, String rewriteUrl) throws IOException, ServletException {
        // copy response status code
        int status = backendResponse.getStatusLine().getStatusCode();
        response.setStatus(status);
        LOGGER.debug("backend response status code: " + status);

        // filter the headers to suppress the authentication dialog (only for
        // 401 - unauthorized)
        List<String> blockedHeaders = null;
        if (status == HttpServletResponse.SC_UNAUTHORIZED && request.getHeader("authorization") != null
                && request.getHeader("suppress-www-authenticate") != null) {
            blockedHeaders = Arrays.asList(new String[] { "www-authenticate" });
        } else {
            // for rewriting the URLs in the response, content-length, content-encoding 
            // and transfer-encoding (for chunked content) headers are removed and handled specially.
            blockedHeaders = Arrays
                    .asList(new String[] { "content-length", "transfer-encoding", "content-encoding" });
        }

        // copy backend response headers and content
        LOGGER.debug("backend response headers: ");
        for (Header header : backendResponse.getAllHeaders()) {
            if (!blockedHeaders.contains(header.getName().toLowerCase())) {
                response.addHeader(header.getName(), header.getValue());
                LOGGER.debug("    => " + header.getName() + ": " + header.getValue());
            } else {
                LOGGER.debug("    => " + header.getName() + ": blocked response header");
            }
        }

        handleContentEncoding(backendResponse);

        // pipe and return the response
        HttpEntity entity = backendResponse.getEntity();
        if (entity != null) {
            // rewrite URL in the content of the response to make sure that
            // internal URLs point to the proxy servlet as well

            // determine charset and content as String
            @SuppressWarnings("deprecation")
            String charset = EntityUtils.getContentCharSet(entity);
            String content = EntityUtils.toString(entity);

            LOGGER.debug("URL rewriting:");
            LOGGER.debug("    => rewriteUrl: " + rewriteUrl);
            LOGGER.debug("    => proxyUrl: " + proxyUrl);

            // replace the rewriteUrl with the targetUrl
            content = content.replaceAll(rewriteUrl, proxyUrl);

            // get the bytes and open a stream (by default HttpClient uses ISO-8859-1)
            byte[] contentBytes = charset != null ? content.getBytes(charset) : content.getBytes(ISO_8859_1);
            InputStream is = new ByteArrayInputStream(contentBytes);

            // set the new content length
            response.setContentLength(contentBytes.length);

            // return the modified content
            pipe(is, response.getOutputStream());
        }

    }

    /**
     * Returns the request that points to the backend service defined by the provided 
     * <code>urlToService</code> URL. The headers of the origin request are copied to 
     * the backend request, except of "host" and "content-length".   
     * 
     * @param request
     *            original request to the Web application
     * @param urlToService
     *            URL to the targeted backend service
     * @return initialized backend service request
     * @throws IOException 
     */
    private HttpRequestBase getBackendRequest(HttpServletRequest request, String urlToService) throws IOException {
        String method = request.getMethod();
        LOGGER.debug("HTTP method: " + method);

        HttpRequestBase backendRequest = null;
        if (HttpPost.METHOD_NAME.equals(method)) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            pipe(request.getInputStream(), out);
            ByteArrayEntity entity = new ByteArrayEntity(out.toByteArray());
            entity.setContentType(request.getHeader("Content-Type"));
            HttpPost post = new HttpPost(urlToService);
            post.setEntity(entity);
            backendRequest = post;
        } else if (HttpGet.METHOD_NAME.equals(method)) {
            HttpGet get = new HttpGet(urlToService);
            backendRequest = get;
        } else if (HttpPut.METHOD_NAME.equals(method)) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            pipe(request.getInputStream(), out);
            ByteArrayEntity entity = new ByteArrayEntity(out.toByteArray());
            entity.setContentType(request.getHeader("Content-Type"));
            HttpPut put = new HttpPut(urlToService);
            put.setEntity(entity);
            backendRequest = put;
        } else if (HttpDelete.METHOD_NAME.equals(method)) {
            HttpDelete delete = new HttpDelete(urlToService);
            backendRequest = delete;
        }

        // copy headers from Web application request to backend request, while
        // filtering the blocked headers

        LOGGER.debug("backend request headers:");

        Collection<String> blockedHeaders = mergeLists(securityHandler, Arrays.asList(BLOCKED_REQUEST_HEADERS));

        Enumeration<String> setCookieHeaders = request.getHeaders("Cookie");
        while (setCookieHeaders.hasMoreElements()) {
            String cookieHeader = setCookieHeaders.nextElement();
            if (blockedHeaders.contains(cookieHeader.toLowerCase())) {
                String replacedCookie = removeJSessionID(cookieHeader);
                backendRequest.addHeader("Cookie", replacedCookie);
            }
            LOGGER.debug("Cookie header => " + cookieHeader);
        }

        for (Enumeration<String> e = request.getHeaderNames(); e.hasMoreElements();) {
            String headerName = e.nextElement().toString();
            if (!blockedHeaders.contains(headerName.toLowerCase())) {
                backendRequest.addHeader(headerName, request.getHeader(headerName));
                LOGGER.debug("    => " + headerName + ": " + request.getHeader(headerName));
            } else {
                LOGGER.debug("    => " + headerName + ": blocked request header");
            }
        }

        return backendRequest;
    }

    private String removeJSessionID(String cookieHeader) {
        int beginIndex = cookieHeader.indexOf("JSESSIONID");
        int endIndex = cookieHeader.indexOf(";", beginIndex + 12);
        String jSeesionSubstring = cookieHeader.substring(beginIndex, endIndex);
        String result = cookieHeader.replace(jSeesionSubstring + ";", "");
        return result;
    }

    private Collection<String> mergeLists(SecurityHandler securityHandler, List<String> blockedHeaders) {
        Set<String> mergedHeadersList = new HashSet<String>();
        mergedHeadersList.addAll(blockedHeaders);
        if (securityHandler != null) {
            List<String> applicationBlackList = securityHandler.getResponseHeadersBlackList();
            mergedHeadersList.addAll(applicationBlackList);
        }

        return mergedHeadersList;
    }

    /**
     * Returns an initialized HttpClient which points to the specified destination.     
     */
    private HttpDestination getDestination(String destinationName) throws ServletException {
        try {
            /*
             * In case the an annotation @Resource is used, the following if block is obsolete
             */
            if (destinationFactory == null) {
                Context ctx = new InitialContext();
                destinationFactory = (DestinationFactory) ctx.lookup(DestinationFactory.JNDI_NAME);
            }
            HttpDestination dest = (HttpDestination) destinationFactory.getDestination(destinationName);
            return dest;
        } catch (Exception e) {
            throw new ServletException(writeMessage("Unable to resolve destination " + destinationName), e);
        }
    }

    /**
     * Returns the destination name defined in the specified URL path. 
     * It is assumed that the specified path consists of following parts:  
     * <pre>
     *  <destinationName>/relativePathToService
     * </pre>
     */
    private String getDestinationFromUrl(String servletPath) throws ServletException {
        String destinationName = null;
        int index = servletPath.lastIndexOf("/");
        if (index != -1) {
            destinationName = servletPath.substring(index + 1, servletPath.length());
        }
        if (destinationName == null) {
            throw new ServletException(writeMessage("No destination specified"));
        }
        LOGGER.debug("destination read from URL path: " + destinationName);
        return destinationName;
    }

    /**
     * Returns the relative path to the backend service. It assumes that the specified path is
     * <pre>
     *  <destinationName>/relativePathToService
     * </pre> 
     * and it returns relativePathToService?queryString.
     */
    private String getRelativePathFromUrl(String path, String queryString) {
        // strip off first label in the path, as it specifies the destination name
        int index = path.indexOf("/");
        String relativePathToService = index != -1 ? path.substring(index + 1) : "";

        // replace spaces with %20 in the path
        relativePathToService = relativePathToService.replace(" ", "%20");

        if (queryString != null && !queryString.isEmpty()) {
            relativePathToService += "?" + queryString;
        }

        LOGGER.debug("relative path to service, incl. query string: " + relativePathToService);
        return relativePathToService;
    }

    private void handleContentEncoding(HttpResponse response) throws ServletException {
        HttpEntity entity = response.getEntity();
        if (entity != null) {
            Header contentEncodingHeader = entity.getContentEncoding();
            if (contentEncodingHeader != null) {
                HeaderElement[] codecs = contentEncodingHeader.getElements();
                LOGGER.debug("Content-Encoding in response:");
                for (HeaderElement codec : codecs) {
                    String codecname = codec.getName().toLowerCase();
                    LOGGER.debug("    => codec: " + codecname);
                    if ("gzip".equals(codecname) || "x-gzip".equals(codecname)) {
                        response.setEntity(new GzipDecompressingEntity(response.getEntity()));
                        return;
                    } else if ("deflate".equals(codecname)) {
                        response.setEntity(new DeflateDecompressingEntity(response.getEntity()));
                        return;
                    } else if ("identity".equals(codecname)) {
                        return;
                    } else {
                        throw new ServletException("Unsupported Content-Encoding: " + codecname);
                    }
                }
            }
        }
    }

    /**
     * Pipes a given <code>InputStream</code> into the given
     * <code>OutputStream</code>
     * 
     * @param in
     *            <code>InputStream</code>
     * @param out
     *            <code>OutputStream</code>
     * @throws IOException
     */
    private static void pipe(InputStream in, OutputStream out) throws IOException {
        byte[] b = new byte[IO_BUFFER_SIZE];
        int read;
        while ((read = in.read(b)) != -1) {
            out.write(b, 0, read);
        }
        in.close();
        out.flush();
        out.close();
    }

    private String writeMessage(String message) {
        StringBuilder b = new StringBuilder();
        b.append("\nInvalid usage: ").append(message);
        b.append("\n");
        b.append("\nUsage of proxy servlet:");
        b.append("\n=======================");
        b.append("\nIt is assumed that the URL to the servlet follows the pattern ");
        b.append("\n==> /<context-path>/proxy/<destination-name>/<relative-path-below-destination-target>");
        b.append("\n");
        return b.toString();
    }
}