ddf.test.itests.platform.TestSingleSignOn.java Source code

Java tutorial

Introduction

Here is the source code for ddf.test.itests.platform.TestSingleSignOn.java

Source

/**
 * Copyright (c) Codice Foundation
 * <p>
 * This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
 * General Public License as published by the Free Software Foundation, either version 3 of the
 * License, or any later version.
 * <p>
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details. A copy of the GNU Lesser General Public License
 * is distributed along with this program and can be found at
 * <http://www.gnu.org/licenses/lgpl.html>.
 */
package ddf.test.itests.platform;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isEmptyOrNullString;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.core.AllOf.allOf;
import static org.hamcrest.core.CombinableMatcher.both;
import static org.junit.Assert.fail;
import static com.jayway.restassured.RestAssured.get;
import static com.jayway.restassured.RestAssured.given;
import static com.jayway.restassured.authentication.CertificateAuthSettings.certAuthSettings;
import static ddf.common.test.WaitCondition.expect;

import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.XMLConstants;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;

import org.apache.commons.io.IOUtils;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.codice.ddf.security.common.jaxrs.RestSecurity;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.ops4j.pax.exam.junit.PaxExam;
import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
import org.ops4j.pax.exam.spi.reactors.PerClass;
import org.osgi.service.cm.Configuration;
import org.xml.sax.SAXException;

import com.jayway.restassured.response.Response;

import ddf.common.test.BeforeExam;
import ddf.security.samlp.SamlProtocol;
import ddf.test.itests.AbstractIntegrationTest;

@RunWith(PaxExam.class)
@ExamReactorStrategy(PerClass.class)
public class TestSingleSignOn extends AbstractIntegrationTest {

    private static final String IDP_AUTH_TYPES = "/=SAML|GUEST,/search=IDP,/cometd=IDP,/solr=SAML|PKI|basic,/services/whoami=IDP|GUEST";

    private static final String KEY_STORE_PATH = System.getProperty("javax.net.ssl.keyStore");

    private static final String PASSWORD = System.getProperty("javax.net.ssl.keyStorePassword");

    private static final DynamicUrl SEARCH_URL = new DynamicUrl(DynamicUrl.SECURE_ROOT, HTTPS_PORT, "/search");

    private static final DynamicUrl IDP_URL = new DynamicUrl(SERVICE_ROOT, "/idp/login");

    private static final DynamicUrl WHO_AM_I_URL = new DynamicUrl(SERVICE_ROOT, "/whoami");

    private static final DynamicUrl AUTHENTICATION_REQUEST_ISSUER = new DynamicUrl(SERVICE_ROOT, "/saml/sso");

    private static final DynamicUrl LOGOUT_REQUEST_URL = new DynamicUrl(SERVICE_ROOT, "/logout/actions");

    private enum Binding {
        REDIRECT {
            @Override
            public String toString() {
                return "Redirect";
            }
        },
        POST {
            @Override
            public String toString() {
                return "POST";
            }
        }
    }

    private enum SamlSchema {
        METADATA, PROTOCOL
    }

    @BeforeExam
    public void beforeTest() throws Exception {
        try {
            basePort = getBasePort();
            getAdminConfig().setLogLevels();
            getServiceManager().waitForRequiredApps(getDefaultRequiredApps());
            getSecurityPolicy().configureWebContextPolicy(null, IDP_AUTH_TYPES, null, null);
            getServiceManager().waitForAllBundles();
            getServiceManager().waitForHttpEndpoint(SERVICE_ROOT + "/catalog/query");
            getServiceManager().waitForHttpEndpoint(WHO_AM_I_URL.getUrl());

            // Start the services needed for testing.
            // We need to start the Search UI to test that it redirects properly
            getServiceManager().startFeature(true, "security-idp", "search-ui-app");
            getServiceManager().waitForAllBundles();

            // Get all of the metadata
            String metadata = get(SERVICE_ROOT + "/idp/login/metadata").asString();
            String ddfSpMetadata = get(SERVICE_ROOT + "/saml/sso/metadata").asString();

            // Make sure all the metadata is valid before we set it
            validateSaml(metadata, SamlSchema.METADATA);
            validateSaml(ddfSpMetadata, SamlSchema.METADATA);

            // The IdP server can point to multiple Service Providers and as such expects an array.
            // Thus, even though we are only setting a single item, we must wrap it in an array.
            setConfig("org.codice.ddf.security.idp.client.IdpMetadata", "metadata", metadata);
            setConfig("org.codice.ddf.security.idp.server.IdpEndpoint", "spMetadata",
                    new String[] { ddfSpMetadata });
        } catch (Exception e) {
            LOGGER.error("Failed in @BeforeExam: ", e);
            fail("Failed in @BeforeExam: " + e.getMessage());
        }
    }

    private void validateSaml(String xml, SamlSchema schema) throws IOException {

        // Prepare the schema and xml
        String schemaFileName = "saml-schema-" + schema.toString().toLowerCase() + "-2.0.xsd";
        URL schemaURL = getClass().getClassLoader().getResource(schemaFileName);
        StreamSource streamSource = new StreamSource(new StringReader(xml));

        // If we fail to create a validator we don't want to stop the show, so we just log a warning
        Validator validator = null;
        try {
            validator = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(schemaURL)
                    .newValidator();
        } catch (SAXException e) {
            LOGGER.warn("Exception creating validator. ", e);
        }

        // If the xml is invalid, then we want to fail completely
        if (validator != null) {
            try {
                validator.validate(streamSource);
            } catch (SAXException e) {
                fail("Failed to validate SAML " + e.getMessage());
            }
        }
    }

    private void setConfig(String pid, String param, Object value) throws Exception {

        // Update the config
        Configuration config = getAdminConfig().getConfiguration(pid, null);
        // @formatter:off
        config.update(new Hashtable<String, Object>() {
            {
                put(param, value);
            }
        });
        // @formatter:on

        // We have to make sure the config has been updated before we can use it
        // @formatter:off
        expect("Configs to update").within(2, TimeUnit.MINUTES)
                .until(() -> config.getProperties() != null && (config.getProperties().get(param) != null));
        // @formatter:on
    }

    private ResponseHelper getSearchResponse(boolean isPassiveRequest) throws Exception {

        // We should be redirected to the IdP when we first try to hit the search page
        // @formatter:off
        Response searchResponse = given().redirects().follow(false).expect().statusCode(302).when()
                .get(SEARCH_URL.getUrl());
        // @formatter:on
        // Because we get back a 302, we know the redirect location is in the header
        ResponseHelper searchHelper = new ResponseHelper(searchResponse);
        searchHelper.parseHeader();

        // We get bounced to a login page which has the parameters we need to pass to the IdP
        // embedded on the page in a JSON Object
        // @formatter:off
        Response redirectResponse = given().params(searchHelper.params).expect().statusCode(200).when()
                .get(searchHelper.redirectUrl);
        // @formatter:on

        // The body has the parameters we passed it plus some extra, so we need to parse again
        ResponseHelper redirectHelper = new ResponseHelper(redirectResponse);
        redirectHelper.parseBody();

        // Make sure the authn request is valid before proceeding
        String inflated = RestSecurity.inflateBase64(redirectHelper.get("SAMLRequest"));
        validateSaml(inflated, SamlSchema.PROTOCOL);

        // The /sso endpoint is hidden to the outside world. Normally we'd be hitting /idp/login
        // with a browser which would show us a login page and redirect us via javascript
        // to /idp/login/sso, but because we are playing the role of the browser we have to do this
        // manually.
        if (isPassiveRequest) {
            redirectHelper.redirectUrl = searchHelper.redirectUrl;
        } else {
            redirectHelper.redirectUrl = searchHelper.redirectUrl + "/sso";
        }

        return redirectHelper;
    }

    private String getUserName() {
        // @formatter:off
        return get(WHO_AM_I_URL.getUrl()).body().asString();
        // @formatter:on
    }

    private String getUserName(Map<String, String> cookies) {
        // @formatter:off
        return given().cookies(cookies).when().get(WHO_AM_I_URL.getUrl()).body().asString();
        // @formatter:on
    }

    private ResponseHelper performHttpRequestUsingBinding(Binding binding, String relayState) throws Exception {

        // Signing is tested in the unit tests, so we don't require signing here to make things simpler
        setConfig("org.codice.ddf.security.idp.server.IdpEndpoint", "strictSignature", false);

        // Set the metadata
        String confluenceSpMetadata = String.format(
                IOUtils.toString(getClass().getResourceAsStream("/confluence-sp-metadata.xml")),
                AUTHENTICATION_REQUEST_ISSUER, binding.toString());
        validateSaml(confluenceSpMetadata, SamlSchema.METADATA);
        setConfig("org.codice.ddf.security.idp.server.IdpEndpoint", "spMetadata",
                new String[] { confluenceSpMetadata });

        // Get the authn request
        String mockAuthnRequest = String.format(
                IOUtils.toString(getClass().getResourceAsStream("/confluence-sp-authentication-request.xml")),
                binding.toString(), AUTHENTICATION_REQUEST_ISSUER);
        validateSaml(mockAuthnRequest, SamlSchema.PROTOCOL);

        String encodedRequest = RestSecurity.deflateAndBase64Encode(mockAuthnRequest);
        // @formatter:off
        Response idpResponse = given().auth().preemptive().basic("admin", "admin").param("AuthMethod", "up")
                .param("SAMLRequest", encodedRequest).param("RelayState", relayState)
                .param("OriginalBinding",
                        Binding.POST == binding ? SamlProtocol.Binding.HTTP_POST.getUri()
                                : SamlProtocol.Binding.HTTP_REDIRECT.getUri())
                .expect().statusCode(200).when().get(IDP_URL.getUrl() + "/sso");
        // @formatter:on
        return new ResponseHelper(idpResponse);
    }

    @Test
    public void testRedirectBinding() throws Exception {
        String relayState = "test";
        ResponseHelper helper = performHttpRequestUsingBinding(Binding.REDIRECT, relayState);

        assertThat(helper.parseBody(), is(Binding.REDIRECT));
        assertThat(helper.get("RelayState"), is(relayState));

        String inflatedSamlResponse = RestSecurity.inflateBase64(helper.get("SAMLResponse"));
        validateSaml(inflatedSamlResponse, SamlSchema.PROTOCOL);
    }

    @Test
    public void testPostBinding() throws Exception {

        // We should get back a POST form that has everything we put in, thus the only thing
        // of interest to really check is that we do get back a post form
        ResponseHelper helper = performHttpRequestUsingBinding(Binding.POST, "test");
        assertThat(helper.parseBody(), is(Binding.POST));
    }

    @Test
    public void testBadUsernamePassword() throws Exception {
        ResponseHelper searchHelper = getSearchResponse(false);

        // We're using an AJAX call, so anything other than 200 means not authenticated
        // @formatter:off
        given().auth().preemptive().basic("definitely", "notright").param("AuthMethod", "up")
                .params(searchHelper.params).expect().statusCode(not(200)).when().get(searchHelper.redirectUrl);
        // @formatter:on
    }

    @Test
    public void testPkiAuth() throws Exception {

        // Note that PKI is passive (as opposed to username/password which is not)
        ResponseHelper searchHelper = getSearchResponse(true);

        // @formatter:off
        given().auth()
                .certificate(KEY_STORE_PATH, PASSWORD,
                        certAuthSettings().sslSocketFactory(SSLSocketFactory.getSystemSocketFactory()))
                .param("AuthMethod", "pki").params(searchHelper.params).expect().statusCode(200).when()
                .get(searchHelper.redirectUrl);
        // @formatter:on
    }

    @Test
    public void testGuestAuth() throws Exception {
        ResponseHelper searchHelper = getSearchResponse(false);

        // @formatter:off
        given().param("AuthMethod", "guest").params(searchHelper.params).expect().statusCode(200).when()
                .get(searchHelper.redirectUrl);
        // @formatter:on
    }

    @Test
    public void testRedirectFlow() throws Exception {

        // Negative test to make sure we aren't admin yet
        assertThat(getUserName(), not("admin"));

        // First time hitting search, expect to get redirected to the Identity Provider.
        ResponseHelper searchHelper = getSearchResponse(false);

        // Pass our credentials to the IDP, it should redirect us to the Assertion Consumer Service.
        // The redirect is currently done via javascript and not an HTTP redirect.
        // @formatter:off
        Response idpResponse = given().auth().preemptive().basic("admin", "admin").param("AuthMethod", "up")
                .params(searchHelper.params).expect().statusCode(200).when().get(searchHelper.redirectUrl);
        // @formatter:on

        ResponseHelper idpHelper = new ResponseHelper(idpResponse);

        // Perform a bunch of checks to make sure we're valid against both the spec and schema
        assertThat(idpHelper.parseBody(), is(Binding.REDIRECT));
        String inflatedSamlResponse = RestSecurity.inflateBase64(idpHelper.get("SAMLResponse"));
        validateSaml(inflatedSamlResponse, SamlSchema.PROTOCOL);
        assertThat(inflatedSamlResponse, allOf(containsString("urn:oasis:names:tc:SAML:2.0:status:Success"),
                containsString("ds:SignatureValue"), containsString("saml2:Assertion")));
        assertThat(idpHelper.get("SigAlg"), not(isEmptyOrNullString()));
        assertThat(idpHelper.get("Signature"), not(isEmptyOrNullString()));
        assertThat(idpHelper.get("RelayState").length(),
                is(both(greaterThanOrEqualTo(0)).and(lessThanOrEqualTo(80))));

        // After passing the SAML Assertion to the ACS, we should be redirected back to Search.
        // @formatter:off
        Response acsResponse = given().params(idpHelper.params).redirects().follow(false).expect()
                .statusCode(anyOf(is(302), is(303))).when().get(idpHelper.redirectUrl);
        // @formatter:on

        ResponseHelper acsHelper = new ResponseHelper(acsResponse);
        acsHelper.parseHeader();

        // Access search again, but now as an authenticated user.
        // @formatter:off
        given().cookies(acsResponse.getCookies()).expect().statusCode(200).when().get(acsHelper.redirectUrl);
        // @formatter:on

        // Make sure we are logged in as admin.
        assertThat(getUserName(acsResponse.getCookies()), is("admin"));
    }

    @Test
    public void testLogout() throws Exception {

        // Negative test to make sure we aren't admin yet
        assertThat(getUserName(), not("admin"));

        // First time hitting search, expect to get redirected to the Identity Provider.
        ResponseHelper searchHelper = getSearchResponse(false);

        // Pass our credentials to the IDP, it should redirect us to the Assertion Consumer Service.
        // The redirect is currently done via javascript and not an HTTP redirect.
        // @formatter:off
        Response idpResponse = given().auth().preemptive().basic("admin", "admin").param("AuthMethod", "up")
                .params(searchHelper.params).expect().statusCode(200).when().get(searchHelper.redirectUrl);
        // @formatter:on

        ResponseHelper idpHelper = new ResponseHelper(idpResponse);
        idpHelper.parseBody();

        // After passing the SAML Assertion to the ACS, we should be redirected back to Search.
        // @formatter:off
        Response acsResponse = given().params(idpHelper.params).redirects().follow(false).expect()
                .statusCode(anyOf(is(302), is(303))).when().get(idpHelper.redirectUrl);
        // @formatter:on

        ResponseHelper acsHelper = new ResponseHelper(acsResponse);
        acsHelper.parseHeader();

        // Access search again, but now as an authenticated user.
        // @formatter:off
        given().cookies(acsResponse.getCookies()).expect().statusCode(200).when().get(acsHelper.redirectUrl);
        // @formatter:on

        // Make sure we are logged in as admin.
        assertThat(getUserName(acsResponse.getCookies()), is("admin"));

        // Create logout request
        // @formatter:off
        Response createLogoutRequest = given().cookies(acsResponse.getCookies()).expect().statusCode(200).when()
                .get(LOGOUT_REQUEST_URL.getUrl());
        // @formatter:on

        ResponseHelper createLogoutHelper = new ResponseHelper(createLogoutRequest);
        createLogoutHelper.parseBody();

        // Logout via url returned in logout request
        // @formatter:off
        given().expect().statusCode(200).body(containsString("Successfully logged out")).when()
                .get(createLogoutHelper.get("url"));
        // @formatter:on

        // Verify admin user is no longer logged in
        assertThat(getUserName(), not("admin"));
    }

    private class ResponseHelper {

        private final Response response;

        private String redirectUrl;

        private final Map<String, String> params = new HashMap<>();

        private ResponseHelper(Response response) throws IOException {
            this.response = response;
        }

        private String get(String key) {
            return params.get(key);
        }

        private void parseParamsFromUrl(String url) throws URISyntaxException {
            redirectUrl = url.split("[?]")[0];

            List<NameValuePair> paramList = URLEncodedUtils.parse(new URI(url), "UTF-8");
            for (NameValuePair param : paramList) {
                params.put(param.getName(), param.getValue());
            }
        }

        private void parseHeader() throws URISyntaxException {
            if (response.headers().hasHeaderWithName("Location")) {
                parseParamsFromUrl(response.header("Location"));
            } else {
                fail("Response does not have a header \"Location\"");
            }
        }

        private Binding parseBody() throws URISyntaxException {

            // Because a POST form only has the stuff we put into it, we don't care
            // about any parsing beyond recognizing it as a form
            String body = response.body().asString();
            Binding binding = null;
            if (body.contains("<form")) {
                binding = Binding.POST;
            } else if (body.contains("<title>Redirect</title>")) {
                parseBodyRedirect();
                binding = Binding.REDIRECT;
            } else if (body.contains("<title>Login</title>")) {
                parseBodyLogin();
            } else if (body.contains("Identity Provider Logout")) {
                parseBodyLogout();
            } else {
                fail("Failed to parse body as redirect or post\n" + body);
            }
            return binding;
        }

        private void parseJson(String json) {
            String[] keyValuePairs = json.split("[,]");
            for (String pair : keyValuePairs) {
                String key = pair.split("[:]", 2)[0].replaceAll("(^\")|(\"$)", "");
                String value = pair.split("[:]", 2)[1].replaceAll("(^\")|(\"$)", "");
                params.put(key, value);
            }
        }

        private void parseBodyLogin() throws URISyntaxException {
            // We're trying to parse a javascript variable that is embedded in an HTML form
            Pattern pattern = Pattern.compile("window.idpState *= *\\{(.*)\\}", Pattern.CASE_INSENSITIVE);
            Matcher matcher = pattern.matcher(response.body().asString());
            if (matcher.find()) {
                parseJson(matcher.group(1));
            } else {
                String failMessage = "Failed to find the javascript var." + "\nPattern: " + pattern.toString()
                        + "\nResponse Body: " + response.body().asString();
                fail(failMessage);
            }
        }

        private void parseBodyRedirect() throws URISyntaxException {

            // We're trying to parse a javascript variable that is embedded in an HTML form
            Pattern pattern = Pattern.compile("encoded *= *\"(.*)\"", Pattern.CASE_INSENSITIVE);
            Matcher matcher = pattern.matcher(response.body().asString());
            if (matcher.find()) {
                parseParamsFromUrl(matcher.group(1));
            } else {
                String failMessage = "Failed to find the javascript var." + "\nPattern: " + pattern.toString()
                        + "\nResponse Body: " + response.body().asString();
                fail(failMessage);
            }
        }

        private void parseBodyLogout() {
            parseJson(response.body().asString());
        }
    }
}