de.tudarmstadt.ukp.shibhttpclient.ShibHttpClient.java Source code

Java tutorial

Introduction

Here is the source code for de.tudarmstadt.ukp.shibhttpclient.ShibHttpClient.java

Source

/*******************************************************************************
 * Licensed 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.
 * 
 * For copyright information, see NOTICE.txt file.
 ******************************************************************************/

package de.tudarmstadt.ukp.shibhttpclient;

import static de.tudarmstadt.ukp.shibhttpclient.Utils.unmarshallMessage;
import static de.tudarmstadt.ukp.shibhttpclient.Utils.xmlToString;
import static java.util.Arrays.asList;

import java.io.IOException;
import java.net.ProxySelector;
import java.security.GeneralSecurityException;
import java.util.List;

import javax.security.sasl.AuthenticationException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpEntity;
import org.apache.http.HttpException;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpResponse;
import org.apache.http.HttpResponseInterceptor;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.NonRepeatableRequestException;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestWrapper;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLContextBuilder;
import org.apache.http.conn.ssl.TrustSelfSignedStrategy;
import org.apache.http.cookie.Cookie;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.conn.SystemDefaultRoutePlanner;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.opensaml.common.xml.SAMLConstants;
import org.opensaml.saml2.core.StatusCode;
import org.opensaml.saml2.ecp.RelayState;
import org.opensaml.saml2.ecp.Response;
import org.opensaml.ws.soap.soap11.Body;
import org.opensaml.ws.soap.soap11.Envelope;
import org.opensaml.ws.soap.soap11.Header;
import org.opensaml.ws.soap.soap11.impl.EnvelopeBuilder;
import org.opensaml.ws.soap.soap11.impl.HeaderBuilder;
import org.opensaml.xml.XMLObject;
import org.opensaml.xml.parse.BasicParserPool;
import org.opensaml.xml.util.Base64;

// deprecated classes we should try to find alternatives for
import org.apache.http.client.params.HttpClientParams;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.params.HttpParams;

/**
 * Simple Shibbolethized {@link HttpClient} using basic HTTP username/password authentication to
 * authenticate against a predefined IdP. The client indicates its ECP capability to the SP.
 * Authentication happens automatically if the SP replies to any requesting using a PAOS
 * authentication solicitation.
 * <p>
 * GET and HEAD requests work completely transparent using redirection. If another request is
 * performed, the client tries a HEAD request to the specified URL first. If this results in an
 * authentication request, a login is performed before the original request is executed.
 */
@SuppressWarnings("deprecation")
public class ShibHttpClient implements HttpClient {
    private final Log log = LogFactory.getLog(getClass());

    private static final String AUTH_IN_PROGRESS = ShibHttpClient.class.getName() + ".AUTH_IN_PROGRESS";

    private static final String MIME_TYPE_PAOS = "application/vnd.paos+xml";

    private static final String HEADER_PAOS = "PAOS";

    private CloseableHttpClient client;

    private BasicCookieStore cookieStore;

    private String idpUrl;

    private String username;

    private String password;

    private BasicParserPool parserPool;

    private static final List<String> REDIRECTABLE = asList("HEAD", "GET", "CONNECT");

    /**
     * Create a new client (assuming we don't accept self-signed certificates)
     * 
     * @param aIdpUrl
     *            the URL of the IdP. Should probably be something ending in "/SAML2/SOAP/ECP"
     * @param aUsername
     *            the user name to log into the IdP.
     * @param aPassword
     *            the password to log in to the IdP.
     */
    public ShibHttpClient(String aIdpUrl, String aUsername, String aPassword) {
        // construct ourselves with our abbreviated set of parameters
        this(aIdpUrl, aUsername, aPassword, false);
    }

    /**
     * Create a new client (assuming we don't need a proxy)
     * 
     * @param aIdpUrl
     *            the URL of the IdP. Should probably be something ending in "/SAML2/SOAP/ECP"
     * @param aUsername
     *            the user name to log into the IdP.
     * @param aPassword
     *            the password to log in to the IdP.
     * @param anyCert
     *            if {@code true}, accept any certificate from any remote host. Otherwise,
     *            certificates need to be installed in the JRE.
     */
    public ShibHttpClient(String aIdpUrl, String aUsername, String aPassword, boolean anyCert) {
        // construct ourselves with our abbreviated set of parameters
        this(aIdpUrl, aUsername, aPassword, null, anyCert);
    }

    /**
     * Create a new client (with an explicit proxy)
     * 
     * @param aIdpUrl
     *            the URL of the IdP. Should probably be something ending in "/SAML2/SOAP/ECP"
     * @param aUsername
     *            the user name to log into the IdP.
     * @param aPassword
     *            the password to log in to the IdP.
     * @param aProxy
     *            if not {@code null}, use this proxy instead of the default system proxy (if any)
     * @param anyCert
     *            if {@code true}, accept any certificate from any remote host. Otherwise,
     *            certificates need to be installed in the JRE.
     */
    public ShibHttpClient(String aIdpUrl, String aUsername, String aPassword, HttpHost aProxy, boolean anyCert) {
        // construct ourselves with our abbreviated set of parameters
        this(aIdpUrl, aUsername, aPassword, aProxy, anyCert, true);
    }

    /**
     * Create a new client (with an explicit proxy and possibly transparent authentication)
     * 
     * @param aIdpUrl
     *            the URL of the IdP. Should probably be something ending in "/SAML2/SOAP/ECP"
     * @param aUsername
     *            the user name to log into the IdP.
     * @param aPassword
     *            the password to log in to the IdP.
     * @param aProxy
     *            if not {@code null}, use this proxy instead of the default system proxy (if any)
     * @param anyCert
     *            if {@code true}, accept any certificate from any remote host. Otherwise,
     *            certificates need to be installed in the JRE.
     * @param transparentAuth
     *            if {@code true} (default), add a HttpRequestPostProcessor to transparently 
     *            authenticate. Otherwise, you must handle the authentication process yourself.
     */
    public ShibHttpClient(String aIdpUrl, String aUsername, String aPassword, HttpHost aProxy, boolean anyCert,
            boolean transparentAuth) {

        setIdpUrl(aIdpUrl);
        setUsername(aUsername);
        setPassword(aPassword);

        // Use a pooling connection manager, because we'll have to do a call out to the IdP
        // while still being in a connection with the SP
        PoolingHttpClientConnectionManager connMgr;
        if (anyCert) {
            try {
                SSLContextBuilder builder = new SSLContextBuilder();
                builder.loadTrustMaterial(null, new TrustSelfSignedStrategy());
                Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder
                        .<ConnectionSocketFactory>create().register("http", new PlainConnectionSocketFactory())
                        .register("https", new SSLConnectionSocketFactory(builder.build(),
                                SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER))
                        .build();
                connMgr = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
            } catch (GeneralSecurityException e) {
                // There shouldn't be any of these exceptions, because we do not use an actual
                // keystore
                throw new IllegalStateException(e);
            }
        } else {
            connMgr = new PoolingHttpClientConnectionManager();
        }
        connMgr.setMaxTotal(10);
        connMgr.setDefaultMaxPerRoute(5);

        // The client needs to remember the auth cookie
        cookieStore = new BasicCookieStore();
        RequestConfig globalRequestConfig = RequestConfig.custom().setCookieSpec(CookieSpecs.BROWSER_COMPATIBILITY)
                .build();

        // Let's throw all common client elements into one builder object
        HttpClientBuilder customClient = HttpClients.custom().setConnectionManager(connMgr)
                // The client needs to remember the auth cookie
                .setDefaultRequestConfig(globalRequestConfig).setDefaultCookieStore(cookieStore)
                // Add the ECP/PAOS headers - needs to be added first so the cookie we get from
                // the authentication can be handled by the RequestAddCookies interceptor later
                .addInterceptorFirst(new HttpRequestPreprocessor());

        // Automatically log into IdP if transparent Shibboleth authentication handling is requested (default)
        if (transparentAuth) {
            customClient = customClient.addInterceptorFirst(new HttpRequestPostprocessor());
        }

        // Build the client with/without proxy settings 
        if (aProxy == null) {
            // use the proxy settings of the JVM, if specified 
            client = customClient.setRoutePlanner(new SystemDefaultRoutePlanner(ProxySelector.getDefault()))
                    .build();
        } else {
            // use the explicit proxy
            client = customClient.setProxy(aProxy).build();
        }

        parserPool = new BasicParserPool();
        parserPool.setNamespaceAware(true);
    }

    public void setIdpUrl(String aIdpUrl) {
        idpUrl = aIdpUrl;
    }

    public void setUsername(String aUsername) {
        username = aUsername;
    }

    public void setPassword(String aPassword) {
        password = aPassword;
    }

    protected static String getAuthInProgress() {
        return AUTH_IN_PROGRESS;
    }

    @Override
    public HttpParams getParams() {
        return client.getParams();
    }

    @Override
    public ClientConnectionManager getConnectionManager() {
        return client.getConnectionManager();
    }

    @Override
    public HttpResponse execute(HttpUriRequest aRequest) throws IOException, ClientProtocolException {
        return client.execute(aRequest);
    }

    @Override
    public HttpResponse execute(HttpUriRequest aRequest, HttpContext aContext)
            throws IOException, ClientProtocolException {
        return client.execute(aRequest, aContext);
    }

    @Override
    public HttpResponse execute(HttpHost aTarget, HttpRequest aRequest)
            throws IOException, ClientProtocolException {
        return client.execute(aTarget, aRequest);
    }

    @Override
    public HttpResponse execute(HttpHost aTarget, HttpRequest aRequest, HttpContext aContext)
            throws IOException, ClientProtocolException {
        return client.execute(aTarget, aRequest, aContext);
    }

    @Override
    public <T> T execute(HttpUriRequest aRequest, ResponseHandler<? extends T> aResponseHandler)
            throws IOException, ClientProtocolException {
        return client.execute(aRequest, aResponseHandler);
    }

    @Override
    public <T> T execute(HttpUriRequest aRequest, ResponseHandler<? extends T> aResponseHandler,
            HttpContext aContext) throws IOException, ClientProtocolException {
        return client.execute(aRequest, aResponseHandler, aContext);
    }

    @Override
    public <T> T execute(HttpHost aTarget, HttpRequest aRequest, ResponseHandler<? extends T> aResponseHandler)
            throws IOException, ClientProtocolException {
        return client.execute(aTarget, aRequest, aResponseHandler);
    }

    @Override
    public <T> T execute(HttpHost aTarget, HttpRequest aRequest, ResponseHandler<? extends T> aResponseHandler,
            HttpContext aContext) throws IOException, ClientProtocolException {
        return client.execute(aTarget, aRequest, aResponseHandler, aContext);
    }

    /** 
     * Checks whether the HttpResponse is a SAML SOAP message
     * @param res the HttpResponse to check
     * @return true if the HttpResponse is a SAML SOAP message, false if not
     */
    protected boolean isSamlSoapResponse(HttpResponse res) {
        boolean isSamlSoap = false;
        if (res.getFirstHeader(HttpHeaders.CONTENT_TYPE) != null) {
            ContentType contentType = ContentType.parse(res.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue());
            isSamlSoap = MIME_TYPE_PAOS.equals(contentType.getMimeType());
        }
        return isSamlSoap;
    }

    /** 
     * Captures the ECP relay state in a SAML SOAP message
     * @param soapEnvelope the SOAP message to check for the ECP relay state
     * @return relayState the ECP relay state in the SOAP message
     */
    protected org.opensaml.saml2.ecp.RelayState captureRelayState(
            org.opensaml.ws.soap.soap11.Envelope soapEnvelope) {
        RelayState relayState = null;
        if (!soapEnvelope.getHeader().getUnknownXMLObjects(RelayState.DEFAULT_ELEMENT_NAME).isEmpty()) {
            relayState = (RelayState) soapEnvelope.getHeader().getUnknownXMLObjects(RelayState.DEFAULT_ELEMENT_NAME)
                    .get(0);
            relayState.detach();
            log.trace("Relay state: captured");
        }
        return relayState;
    }

    /**
     * Extracts the SOAP message from the HttpResponse
     * @param entity the HttpEntity to retrieve the SOAP message from
     * @return soapEnvelope the SOAP message 
     * @throws IOException 
     * @throws IllegalStateException 
     * @throws ClientProtocolException 
     */
    protected org.opensaml.ws.soap.soap11.Envelope getSoapMessage(HttpEntity entity)
            throws ClientProtocolException, IllegalStateException, IOException {
        Envelope soapEnvelope = (Envelope) unmarshallMessage(parserPool, entity.getContent());
        EntityUtils.consumeQuietly(entity);
        return soapEnvelope;
    }

    /**
     * Add the ECP/PAOS headers to each outgoing request.
     */
    private final class HttpRequestPreprocessor implements HttpRequestInterceptor {
        @Override
        public void process(final HttpRequest req, final HttpContext ctx) throws HttpException, IOException {
            req.addHeader(HttpHeaders.ACCEPT, MIME_TYPE_PAOS);
            req.addHeader(HEADER_PAOS,
                    "ver=\"" + SAMLConstants.PAOS_NS + "\";\"" + SAMLConstants.SAML20ECP_NS + "\"");

            HttpRequest r = req;
            if (req instanceof HttpRequestWrapper) { // does not forward request to original
                r = ((HttpRequestWrapper) req).getOriginal();
            }

            // This request is not redirectable, so we better knock to see if authentication
            // is necessary.
            if (!REDIRECTABLE.contains(r.getRequestLine().getMethod())
                    && r.getParams().isParameterFalse(AUTH_IN_PROGRESS)) {
                //                    && !r.getRequestLine().getUri().startsWith(idpUrl)) {
                log.trace("Unredirectable request [" + r.getRequestLine().getMethod()
                        + "], trying to knock first at " + r.getRequestLine().getUri());
                HttpHead knockRequest = new HttpHead(r.getRequestLine().getUri());
                client.execute(knockRequest);

                for (Cookie c : cookieStore.getCookies()) {
                    log.trace(c.toString());
                }
                log.trace("Knocked");
            }
        }
    }

    /**
     * Analyse responses to detect PAOS solicitations for an authentication. Answer these and then
     * transparently proceed with the original request.
     */
    public final class HttpRequestPostprocessor implements HttpResponseInterceptor {
        @Override
        public void process(HttpResponse res, HttpContext ctx) throws HttpException, IOException {
            HttpRequest originalRequest;
            // check for RequestWrapper objects, retrieve the original request
            if (ctx.getAttribute("http.request") instanceof HttpRequestWrapper) { // does not forward request to original
                log.trace("RequestWrapper found");
                originalRequest = (HttpRequest) ((HttpRequestWrapper) ctx.getAttribute("http.request"))
                        .getOriginal();
            } else { // use a basic HttpRequest because BasicHttpRequest objects cannot be recast to HttpUriRequest objects
                originalRequest = (HttpRequest) ctx.getAttribute("http.request");
            }

            log.trace("Accessing [" + originalRequest.getRequestLine().getUri() + " "
                    + originalRequest.getRequestLine().getMethod() + "]");

            // -- Check if authentication is already in progress ----------------------------------
            if (res.getParams().isParameterTrue(AUTH_IN_PROGRESS)) {
                log.trace("Authentication in progress -- skipping post processor");
                return;
            }

            // -- Check if authentication is necessary --------------------------------------------
            if (!isSamlSoapResponse(res)) {
                return;
            }

            log.trace("Detected login request");

            // -- If the request was a HEAD request, we need to try again using a GET request  ----
            HttpResponse paosResponse = res;
            if (originalRequest.getRequestLine().getMethod() == "HEAD") {
                log.trace("Original request was a HEAD, restarting authenticiation with GET");

                HttpGet authTriggerRequest = new HttpGet(originalRequest.getRequestLine().getUri());
                authTriggerRequest.getParams().setBooleanParameter(AUTH_IN_PROGRESS, true);
                paosResponse = client.execute(authTriggerRequest);
            }

            // -- Parse PAOS response -------------------------------------------------------------
            Envelope initialLoginSoapResponse = getSoapMessage(paosResponse.getEntity());

            // -- Capture relay state (optional) --------------------------------------------------
            RelayState relayState = captureRelayState(initialLoginSoapResponse);

            // -- Capture response consumer -------------------------------------------------------
            //            // pick out the responseConsumerURL attribute value from the SP response so that
            //            // it can later be compared to the assertionConsumerURL sent from the IdP
            //            String responseConsumerURL = ((XSAny) initialLoginSoapResponse.getHeader()
            //                    .getUnknownXMLObjects(E_PAOS_REQUEST).get(0)).getUnknownAttributes().get(
            //                    A_RESPONSE_CONSUMER_URL);
            //            log.debug("responseConsumerURL: [" + responseConsumerURL + "]");

            // -- Send log-in request to the IdP --------------------------------------------------
            // Prepare the request to the IdP
            log.debug("Logging in to IdP [" + idpUrl + "]");
            Envelope idpLoginSoapRequest = new EnvelopeBuilder().buildObject();
            Body b = initialLoginSoapResponse.getBody();
            b.detach();
            idpLoginSoapRequest.setBody(b);

            // Try logging in to the IdP using HTTP BASIC authentication
            HttpPost idpLoginRequest = new HttpPost(idpUrl);
            idpLoginRequest.getParams().setBooleanParameter(AUTH_IN_PROGRESS, true);
            idpLoginRequest.addHeader(HttpHeaders.AUTHORIZATION,
                    "Basic " + Base64.encodeBytes((username + ":" + password).getBytes()));
            idpLoginRequest.setEntity(new StringEntity(xmlToString(idpLoginSoapRequest)));
            HttpResponse idpLoginResponse = client.execute(idpLoginRequest);

            // -- Handle log-in response from the IdP ---------------------------------------------
            log.debug("Status: " + idpLoginResponse.getStatusLine());
            if (idpLoginResponse.getStatusLine().getStatusCode() != 200) {
                throw new AuthenticationException(idpLoginResponse.getStatusLine().toString());
            }

            Envelope idpLoginSoapResponse = getSoapMessage(idpLoginResponse.getEntity());
            String assertionConsumerServiceURL = ((Response) idpLoginSoapResponse.getHeader()
                    .getUnknownXMLObjects(Response.DEFAULT_ELEMENT_NAME).get(0)).getAssertionConsumerServiceURL();
            log.debug("assertionConsumerServiceURL: " + assertionConsumerServiceURL);

            List<XMLObject> responses = idpLoginSoapResponse.getBody()
                    .getUnknownXMLObjects(org.opensaml.saml2.core.Response.DEFAULT_ELEMENT_NAME);
            if (!responses.isEmpty()) {
                org.opensaml.saml2.core.Response response = (org.opensaml.saml2.core.Response) responses.get(0);

                // Get root code (?)
                StatusCode sc = response.getStatus().getStatusCode();
                while (sc.getStatusCode() != null) {
                    sc = sc.getStatusCode();
                }

                // Hm, they don't like us
                if (StatusCode.AUTHN_FAILED_URI.equals(sc.getValue())) {
                    throw new AuthenticationException(sc.getValue());
                }
            }

            // compare the responseConsumerURL from the SP to the assertionConsumerServiceURL from
            // the IdP and if they are not identical then send a SOAP fault to the SP
            // if (false) {
            //     // Nice guys should send a fault to the SP - we are NOT nice yet
            // }

            // -- Forward ticket to the SP --------------------------------------------------------
            // craft the package to send to the SP by copying the response from the IdP but
            // removing the SOAP header sent by the IdP and instead putting in a new header that
            // includes the relay state sent by the SP
            Header header = new HeaderBuilder().buildObject();
            header.getUnknownXMLObjects().clear();
            if (relayState != null) {
                header.getUnknownXMLObjects().add(relayState);
            }
            idpLoginSoapResponse.setHeader(header);

            // push the response to the SP at the assertion consumer service URL included in
            // the response from the IdP
            log.debug("Logging in to SP");
            HttpPost spLoginRequest = new HttpPost(assertionConsumerServiceURL);
            spLoginRequest.getParams().setBooleanParameter(AUTH_IN_PROGRESS, true);
            spLoginRequest.setHeader(HttpHeaders.CONTENT_TYPE, MIME_TYPE_PAOS);
            spLoginRequest.setEntity(new StringEntity(xmlToString(idpLoginSoapResponse)));
            HttpClientParams.setRedirecting(spLoginRequest.getParams(), false);
            HttpResponse spLoginResponse = client.execute(spLoginRequest);
            log.debug("Status: " + spLoginResponse.getStatusLine());
            log.debug("Authentication complete");

            // -- Handle unredirectable cases -----------------------------------------------------
            // If we get a redirection and the request is redirectable, then let the client redirect
            // If the request is not redirectable, signal that the operation must be retried.
            if (spLoginResponse.getStatusLine().getStatusCode() == 302
                    && !REDIRECTABLE.contains(originalRequest.getRequestLine().getMethod())) {
                EntityUtils.consume(spLoginResponse.getEntity());
                throw new NonRepeatableRequestException("Request of type ["
                        + originalRequest.getRequestLine().getMethod() + "] cannot be redirected");
            }

            // -- Transparently return response to original request -------------------------------
            // Return response received after login as actual response to original caller
            res.setEntity(spLoginResponse.getEntity());
            res.setHeaders(spLoginResponse.getAllHeaders());
            res.setStatusLine(spLoginResponse.getStatusLine());
        }
    }
}