/**
* 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.codice.ddf.itests.common.WaitCondition.expect;
import static org.codice.ddf.itests.common.catalog.CatalogTestCommons.ingest;
import static org.codice.ddf.itests.common.opensearch.OpenSearchTestCommons.OPENSEARCH_FACTORY_PID;
import static org.codice.ddf.itests.common.opensearch.OpenSearchTestCommons.getOpenSearchSourceProperties;
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.hasXPath;
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 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.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.codice.ddf.itests.common.AbstractIntegrationTest;
import org.codice.ddf.itests.common.annotations.BeforeExam;
import org.codice.ddf.itests.common.utils.LoggingUtils;
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.PerSuite;
import org.osgi.service.cm.Configuration;
import org.xml.sax.SAXException;
import com.jayway.restassured.response.Response;
import ddf.catalog.data.Metacard;
import ddf.security.samlp.SamlProtocol;
@RunWith(PaxExam.class)
@ExamReactorStrategy(PerSuite.class)
public class TestSingleSignOn extends AbstractIntegrationTest {
private static final String IDP_AUTH_TYPES =
"/=IDP|GUEST,/solr=SAML|PKI|basic";
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 static final String RECORD_TITLE_1 = "myTitle";
public static final String BROWSER_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36";
private static String metacardId;
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 {
waitForSystemReady();
// Start the services needed for testing.
// We need to start the Search UI to test that it redirects properly
getServiceManager().startFeature(true, "security-idp");
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});
metacardId = ingest(getFileContent(JSON_RECORD_RESOURCE_PATH + "/SimpleGeoJsonRecord"),
"application/json");
getSecurityPolicy().configureWebContextPolicy(null, IDP_AUTH_TYPES, null, null);
getServiceManager().waitForAllBundles();
getServiceManager().waitForHttpEndpoint(SERVICE_ROOT + "/catalog/query");
getServiceManager().waitForHttpEndpoint(WHO_AM_I_URL.getUrl());
getServiceManager().waitForHttpEndpoint(SERVICE_ROOT + "/idp/login/metadata");
getServiceManager().waitForHttpEndpoint(SERVICE_ROOT + "/saml/sso/metadata");
Map<String, Object> openSearchProperties = getOpenSearchSourceProperties(
OPENSEARCH_SOURCE_ID,
OPENSEARCH_PATH.getUrl(),
getServiceManager());
openSearchProperties.put("username", "admin");
openSearchProperties.put("password", "admin");
getServiceManager().createManagedService(OPENSEARCH_FACTORY_PID, openSearchProperties);
getCatalogBundle().waitForFederatedSource(OPENSEARCH_SOURCE_ID);
} catch (Exception e) {
LoggingUtils.failWithThrowableStacktrace(e, "Failed in @BeforeExam: ");
}
}
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 = AbstractIntegrationTest.class.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.debug("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) {
LoggingUtils.failWithThrowableStacktrace(e, "Failed to validate SAML: ");
}
}
}
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, String url)
throws Exception {
// We should be redirected to the IdP when we first try to hit the search page
// @formatter:off
Response searchResponse =
given().
header("User-Agent", BROWSER_USER_AGENT).
redirects().follow(false).
expect().
statusCode(302).
when().
get(url == null ? SEARCH_URL.getUrl() : url);
// @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().
header("User-Agent", BROWSER_USER_AGENT).
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(getFileContent("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(getFileContent(
"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, null);
// 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, null);
// @formatter:off
given().
auth().
certificate(KEY_STORE_PATH, PASSWORD, certAuthSettings()
.sslSocketFactory(SSLSocketFactory.getSystemSocketFactory())).
header("User-Agent", BROWSER_USER_AGENT).
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, null);
// @formatter:off
given().
param("AuthMethod", "guest").
params(searchHelper.params).
header("User-Agent", BROWSER_USER_AGENT).
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, null);
// 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, null);
// 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("You are now signed out.")).
when().
get(createLogoutHelper.get("url"));
// @formatter:on
// Verify admin user is no longer logged in
assertThat(getUserName(), not("admin"));
}
@Test
public void testEcpByFederatedQueryWithUsernamePassword() throws Exception {
String queryUrl = OPENSEARCH_PATH.getUrl() + "?q=*&format=xml&src=" + OPENSEARCH_SOURCE_ID;
// First time hitting search, expect to get redirected to the Identity Provider.
ResponseHelper searchHelper = getSearchResponse(false, queryUrl);
// 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();
Response response = given().
cookies(acsResponse.getCookies())
.
expect()
.
statusCode(200)
.
when()
.
get(queryUrl);
//The federated query using username/password against the IDP auth type on all of /services would fail without ECP
// @formatter:off
response.then().log().all().assertThat().body(hasXPath(
"/metacards/metacard/string[@name='" + Metacard.TITLE + "']/value[text()='"
+ RECORD_TITLE_1 + "']"), hasXPath("/metacards/metacard/geometry/value"));
// @formatter:on
}
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());
}
}
}