/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2012, Red Hat, Inc., and individual contributors
 * as indicated by the @author tags. See the copyright.txt file in the
 * distribution for a full listing of individual contributors.
 *
 * 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 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software 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.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */

package org.jboss.as.test.integration.security.picketlink;

import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.hasItem;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.junit.Assert.assertThat;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import javax.naming.Context;
import javax.security.auth.Subject;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import javax.servlet.http.HttpServletResponse;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthSchemeProvider;
import org.apache.http.auth.AuthScope;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.config.AuthSchemes;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.impl.client.BasicCredentialsProvider;
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.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.util.EntityUtils;
import org.hamcrest.Matcher;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.container.test.api.OperateOnDeployment;
import org.jboss.arquillian.container.test.api.RunAsClient;
import org.jboss.arquillian.junit.Arquillian;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.jboss.as.arquillian.api.ServerSetup;
import org.jboss.as.arquillian.api.ServerSetupTask;
import org.jboss.as.arquillian.container.ManagementClient;
import org.jboss.as.network.NetworkUtils;
import org.jboss.as.security.Constants;
import org.jboss.as.test.integration.security.common.AbstractKrb5ConfServerSetupTask;
import org.jboss.as.test.integration.security.common.AbstractSecurityDomainsServerSetupTask;
import org.jboss.as.test.integration.security.common.Krb5LoginConfiguration;
import org.jboss.as.test.integration.security.common.NullHCCredentials;
import org.jboss.as.test.integration.security.common.Utils;
import org.jboss.as.test.integration.security.common.config.SecurityDomain;
import org.jboss.as.test.integration.security.common.config.SecurityModule;
import org.jboss.as.test.integration.security.common.negotiation.JBossNegotiateSchemeFactory;
import org.jboss.as.test.integration.security.common.negotiation.KerberosTestUtils;
import org.jboss.as.test.integration.security.common.servlets.PrincipalPrintingServlet;
import org.jboss.as.test.integration.security.common.servlets.RolePrintingServlet;
import org.jboss.logging.Logger;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;

/**
 * Tests for integration of the IDP and Kerberos.
 *
 * @author Hynek Mlnarik
 */
@RunWith(Arquillian.class)
@ServerSetup({
        KerberosServerSetupTask.Krb5ConfServerSetupTask.class,
        KerberosServerSetupTask.SystemPropertiesSetup.class,
        KerberosServerSetupTask.class,
        SAML2KerberosAuthenticationTestCase.SecurityDomainsSetup.class
})
@RunAsClient
@Ignore("AS7-6796 - Undertow SPNEGO")
public class SAML2KerberosAuthenticationTestCase {

    private static final String SERVICE_PROVIDER_NAME = "SP_DEPLOYMENT";
    private static final String IDENTITY_PROVIDER_NAME = "IDP_DEPLOYMENT";

    private static final String SP_DEPLOYMENT_NAME = "test-" + SERVICE_PROVIDER_NAME;
    private static final String IDP_DEPLOYMENT_NAME = "idp-test-" + SERVICE_PROVIDER_NAME;

    private static final String SERVICE_PROVIDER_REALM = "spRealm";
    private static final String IDENTITY_PROVIDER_REALM = IDP_DEPLOYMENT_NAME;

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

    private static final String PICKETLINK_MODULE_NAME = "org.picketlink";
    private static final String JBOSS_NEGOTIATION_MODULE_NAME = "org.jboss.security.negotiation";

    private static final String DUKE_PASSWORD = "theduke";

    @ArquillianResource
    ManagementClient mgmtClient;

    private static void consumeResponse(final HttpResponse response) {
        HttpEntity entity = response.getEntity();
        EntityUtils.consumeQuietly(entity);
    }

    // Public methods --------------------------------------------------------

    /**
     * Skip unsupported/unstable/buggy Kerberos configurations.
     */
    @Before
    public static void before() {
        KerberosTestUtils.assumeKerberosAuthenticationSupported();
    }

    /**
     * Creates a {@link WebArchive} for given security domain.
     *
     * @return
     */
    @Deployment(name = SERVICE_PROVIDER_NAME)
    public static WebArchive createSpWar() {
        final WebArchive war = ShrinkWrap.create(WebArchive.class, SP_DEPLOYMENT_NAME + ".war");
        war.addClasses(RolePrintingServlet.class, PrincipalPrintingServlet.class);
        war.addAsWebInfResource(SAML2KerberosAuthenticationTestCase.class.getPackage(),
                SAML2KerberosAuthenticationTestCase.class.getSimpleName() + "-web.xml", "web.xml");

        war.addAsWebInfResource(Utils.getJBossWebXmlAsset(SERVICE_PROVIDER_REALM,
                "org.picketlink.identity.federation.bindings.tomcat.sp.ServiceProviderAuthenticator"), "jboss-web.xml");

        war.addAsManifestResource(Utils.getJBossDeploymentStructure(PICKETLINK_MODULE_NAME, JBOSS_NEGOTIATION_MODULE_NAME),
                "jboss-deployment-structure.xml");
        war.addAsWebInfResource(
                new StringAsset(PicketLinkTestBase.propertiesReplacer("picketlink-sp.xml", SP_DEPLOYMENT_NAME, "REDIRECT",
                        IDP_DEPLOYMENT_NAME)), "picketlink.xml");

        war.add(new StringAsset("Welcome to deployment: " + SP_DEPLOYMENT_NAME), "index.jsp");

        return war;
    }

    /**
     * Creates a {@link WebArchive} for given security domain.
     *
     * @return
     */
    @Deployment(name = IDENTITY_PROVIDER_NAME)
    public static WebArchive createIdpWar() {
        final WebArchive war = ShrinkWrap.create(WebArchive.class, IDP_DEPLOYMENT_NAME + ".war");
        war.addAsWebInfResource(SAML2KerberosAuthenticationTestCase.class.getPackage(),
                SAML2KerberosAuthenticationTestCase.class.getSimpleName() + "-idp-web.xml", "web.xml");

        war.addAsWebInfResource(Utils.getJBossWebXmlAsset(IDP_DEPLOYMENT_NAME,
                "org.jboss.security.negotiation.NegotiationAuthenticator",
                "org.picketlink.identity.federation.bindings.tomcat.idp.IDPWebBrowserSSOValve"), "jboss-web.xml");

        war.addAsManifestResource(Utils.getJBossDeploymentStructure(PICKETLINK_MODULE_NAME, JBOSS_NEGOTIATION_MODULE_NAME),
                "jboss-deployment-structure.xml");
        war.addAsWebInfResource(
                new StringAsset(PicketLinkTestBase.propertiesReplacer("picketlink-idp.xml", IDP_DEPLOYMENT_NAME, "",
                        IDP_DEPLOYMENT_NAME)), "picketlink.xml");
        war.addAsWebResource(SAML2KerberosAuthenticationTestCase.class.getPackage(), "error.jsp", "error.jsp");
        war.addAsWebResource(SAML2KerberosAuthenticationTestCase.class.getPackage(), "login.jsp", "login.jsp");
        war.add(new StringAsset("Welcome to IdP"), "index.jsp");
        war.add(new StringAsset("Welcome to IdP hosted"), "hosted/index.jsp");
        return war;
    }

    /**
     * Test for SPNEGO working.
     *
     * @throws Exception
     */
    @Test
    @OperateOnDeployment(SERVICE_PROVIDER_NAME)
    public void testNegotiateHttpHeader(@ArquillianResource URL webAppURL,
                                        @ArquillianResource @OperateOnDeployment(IDENTITY_PROVIDER_NAME) URL idpURL) throws Exception {

        try (CloseableHttpClient httpClient = HttpClients.createDefault()){
            final HttpGet httpGet = new HttpGet(webAppURL.toURI());
            final HttpResponse response = httpClient.execute(httpGet);

            assertThat("Unexpected status code.", response.getStatusLine().getStatusCode(),
                    equalTo(HttpServletResponse.SC_UNAUTHORIZED));

            final Header[] authnHeaders = response.getHeaders("WWW-Authenticate");
            assertThat("WWW-Authenticate header is present", authnHeaders, notNullValue());
            assertThat("WWW-Authenticate header is non-empty", authnHeaders.length, not(equalTo(0)));

            final Set<? super String> authnHeaderValues = new HashSet<String>();
            for (final Header header : authnHeaders) {
                authnHeaderValues.add(header.getValue());
            }

            Matcher<String> matcherContainsString = containsString("Negotiate");
            Matcher<Iterable<? super String>> matcherAnyContainsNegotiate = hasItem(matcherContainsString);
            assertThat("WWW-Authenticate [Negotiate] header is missing", authnHeaderValues, matcherAnyContainsNegotiate);

            consumeResponse(response);
        }
    }

    /**
     * Test roles for jduke user.
     *
     * @throws Exception
     */
    @Test
    @OperateOnDeployment(SERVICE_PROVIDER_NAME)
    public void testJDukeRoles(@ArquillianResource URL webAppURL,
                               @ArquillianResource @OperateOnDeployment(IDENTITY_PROVIDER_NAME) URL idpURL) throws Exception {
        final URI rolesPrintingURL = new URI(webAppURL.toExternalForm() + RolePrintingServlet.SERVLET_PATH.substring(1)
                + "?test=testDeploymentViaKerberos&" + KerberosServerSetupTask.QUERY_ROLES);

        String responseBody = makeCallWithKerberosAuthn(rolesPrintingURL, idpURL.toURI(), "jduke", DUKE_PASSWORD);

        final List<String> assignedRolesList = Arrays.asList("TheDuke", "Echo", "Admin");
        for (String role : KerberosServerSetupTask.ROLE_NAMES) {
            if (assignedRolesList.contains(role)) {
                assertThat("Missing role assignment", responseBody, containsString("," + role + ","));
            } else {
                assertThat("Unexpected role assignment", responseBody, not(containsString("," + role + ",")));
            }
        }
    }

    /**
     * Test principal for jduke user.
     *
     * @throws Exception
     */
    @Test
    @OperateOnDeployment(SERVICE_PROVIDER_NAME)
    public void testJDukePrincipal(@ArquillianResource URL webAppURL,
                                   @ArquillianResource @OperateOnDeployment(IDENTITY_PROVIDER_NAME) URL idpURL) throws Exception {
        final String cannonicalHost = Utils.getCannonicalHost(mgmtClient);
        final URI principalPrintingURL = new URI(webAppURL.toExternalForm()
                + PrincipalPrintingServlet.SERVLET_PATH.substring(1) + "?test=testDeploymentViaKerberos");
        String responseBody = makeCallWithKerberosAuthn(principalPrintingURL,
                Utils.replaceHost(idpURL.toURI(), cannonicalHost), "jduke", DUKE_PASSWORD);

        assertThat("Unexpected principal", responseBody, equalTo("jduke"));
    }

    // Private methods -------------------------------------------------------

    /**
     * Returns response body for the given URL request as a String. It also checks if the returned HTTP status code is the
     * expected one. If the server returns {@link HttpServletResponse#SC_UNAUTHORIZED} and an username is provided, then the
     * given user is authenticated against Kerberos and a new request is executed under the new subject.
     *
     * @param uri  URI to which the request should be made
     * @param user Username
     * @param pass Password
     * @return HTTP response body
     * @throws IOException
     * @throws URISyntaxException
     * @throws PrivilegedActionException
     * @throws LoginException
     */
    public static String makeCallWithKerberosAuthn(URI uri, URI idpUri, final String user, final String pass)
            throws IOException, URISyntaxException,
            PrivilegedActionException, LoginException {

        final String canonicalHost = Utils.getDefaultHost(true);
        uri = Utils.replaceHost(uri, canonicalHost);
        idpUri = Utils.replaceHost(idpUri, canonicalHost);

        LOGGER.trace("Making call to: " + uri);
        LOGGER.trace("Expected IDP: " + idpUri);

        final Krb5LoginConfiguration krb5configuration = new Krb5LoginConfiguration(Utils.getLoginConfiguration());
        // Use our custom configuration to avoid reliance on external config
        Configuration.setConfiguration(krb5configuration);

        // 1. Authenticate to Kerberos.
        final LoginContext lc = Utils.loginWithKerberos(krb5configuration, user, pass);

        // 2. Perform the work as authenticated Subject.
        final String responseBody = Subject.doAs(lc.getSubject(), new HttpGetInKerberos(uri, idpUri));
        lc.logout();
        krb5configuration.resetConfiguration();
        return responseBody;
    }

    // Inner classes ------------------------------------------------------

    /**
     * A {@link ServerSetupTask} instance which creates security domains for this test case.
     *
     * @author Hynek Mlnarik
     */
    static class SecurityDomainsSetup extends AbstractSecurityDomainsServerSetupTask {

        private static final String SERVER_SECURITY_DOMAIN = "host";

        /**
         * Returns SecurityDomains configuration for this testcase.
         *
         * @see org.jboss.as.test.integration.security.common.AbstractSecurityDomainsServerSetupTask#getSecurityDomains()
         */
        @Override
        protected SecurityDomain[] getSecurityDomains() {
            List<SecurityDomain> res = new LinkedList<SecurityDomain>();

            // Add host security domain
            res.add(new SecurityDomain.Builder()
                    .name(SERVER_SECURITY_DOMAIN)
                    .cacheType("default")
                    .loginModules(
                            new SecurityModule.Builder()
                                    .name(Krb5LoginConfiguration.getLoginModule())
                                    .flag(Constants.REQUIRED)
                                    .options(
                                            Krb5LoginConfiguration.getOptions(
                                                    KerberosServerSetupTask.getHttpServicePrincipal(managementClient),
                                                    AbstractKrb5ConfServerSetupTask.HTTP_KEYTAB_FILE, true)).build()).build());

            // Add IdP security domain
            res.add(new SecurityDomain.Builder()
                    .name(IDENTITY_PROVIDER_REALM)
                    .loginModules(
                            new SecurityModule.Builder()
                                    // Login module used for password negotiation
                                    .name("SPNEGO").flag(Constants.REQUISITE).putOption("password-stacking", "useFirstPass")
                                    .putOption("serverSecurityDomain", SERVER_SECURITY_DOMAIN)
                                    .putOption("removeRealmFromPrincipal", "true").build(),

                            new SecurityModule.Builder()
                                    // Login module used for role retrieval
                                    .name("org.jboss.security.auth.spi.LdapExtLoginModule")
                                    .flag(Constants.REQUIRED)
                                    .putOption("password-stacking", "useFirstPass")
                                    .putOption(
                                            Context.PROVIDER_URL,
                                            "ldap://"
                                                    + NetworkUtils.formatPossibleIpv6Address(Utils
                                                    .getCannonicalHost(managementClient)) + ":"
                                                    + KerberosServerSetupTask.LDAP_PORT)
                                    .putOption("baseCtxDN", "ou=People,dc=jboss,dc=org").putOption("baseFilter", "(uid={0})")
                                    .putOption("rolesCtxDN", "ou=Roles,dc=jboss,dc=org")
                                    .putOption("roleFilter", "(|(objectClass=referral)(member={1}))")
                                    .putOption("roleAttributeID", "cn").putOption("referralUserAttributeIDToCheck", "member")
                                    .putOption("bindDN", KerberosServerSetupTask.SECURITY_PRINCIPAL)
                                    .putOption("bindCredential", KerberosServerSetupTask.SECURITY_CREDENTIALS)
                                    .putOption(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory")
                                    .putOption(Context.SECURITY_AUTHENTICATION, "simple").putOption(Context.REFERRAL, "follow")
                                    .putOption("throwValidateError", "true").putOption("roleRecursion", "5").build()).build());

            // Add SP security domain
            res.add(new SecurityDomain.Builder()
                    .name(SERVICE_PROVIDER_REALM)
                    .loginModules(
                            new SecurityModule.Builder()
                                    .name("org.picketlink.identity.federation.bindings.jboss.auth.SAML2LoginModule")
                                    .flag(Constants.REQUIRED).build()).build());

            return res.toArray(new SecurityDomain[0]);
        }
    }

    /**
     * Class which is intended to be run in context of a Kerberos-authenticated user, to test the http authentication via IdP.
     */
    private static class HttpGetInKerberos implements PrivilegedExceptionAction<String> {

        private final URI uri;
        private final URI idpUri;

        /**
         * Initializes the instance.
         *
         * @param uri    URI of the web application
         * @param idpUri URI of the respective identity provider
         */
        HttpGetInKerberos(URI uri, URI idpUri) {
            this.uri = uri;
            this.idpUri = idpUri;
        }

        /**
         * Performs authentication via IdP and retrieves the document body from the {@link #uri}.
         *
         * @return Body of the response retrieved from {@link #uri}
         * @throws Exception
         */
        @Override
        public String run() throws Exception {
            Registry<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>create()
                    .register(AuthSchemes.SPNEGO, new JBossNegotiateSchemeFactory(true))
                    .build();

            CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
            credentialsProvider.setCredentials(new AuthScope(null, -1, null), new NullHCCredentials());


            /*final DefaultHttpClient httpClient = new DefaultHttpClient();
            httpClient.getAuthSchemes().register(AuthPolicy.SPNEGO, new JBossNegotiateSchemeFactory(true));
            httpClient.getCredentialsProvider().setCredentials(new AuthScope(null, -1, null), new NullHCCredentials());
*/
            final HttpParams doNotRedirect = new BasicHttpParams();
            doNotRedirect.setParameter(ClientPNames.HANDLE_REDIRECTS, false);
            doNotRedirect.setParameter(ClientPNames.HANDLE_AUTHENTICATION, true);

            final HttpParams doRedirect = new BasicHttpParams();
            doRedirect.setParameter(ClientPNames.HANDLE_AUTHENTICATION, true);
            doRedirect.setParameter(ClientPNames.HANDLE_REDIRECTS, true);

            try (final CloseableHttpClient httpClient = HttpClientBuilder.create()
                                   .setDefaultAuthSchemeRegistry(authSchemeRegistry)
                                   .setDefaultCredentialsProvider(credentialsProvider)
                                   .build()){
                // 1. Login to IdP
                HttpGet initialIdpHttpGet = new HttpGet(this.idpUri); // GET /idp-test-DEP1
                initialIdpHttpGet.setParams(doRedirect);
                HttpResponse response = httpClient.execute(initialIdpHttpGet);
                assertThat("Unexpected status code when expecting successfull kerberos authentication", response
                        .getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_OK));
                consumeResponse(response);

                // 2. Do the work, manually do the redirect
                HttpGet initialHttpGet = new HttpGet(this.uri); // GET /test-DEP1/printRoles?role=TheDuke2&role=...
                initialHttpGet.setParams(doNotRedirect);
                response = httpClient.execute(initialHttpGet);
                assertThat("Unexpected status code when expecting redirect to IdP", response.getStatusLine().getStatusCode(),
                        equalTo(HttpStatus.SC_MOVED_TEMPORARILY));
                String initialHttpGetRedirect = response.getFirstHeader(HttpHeaders.LOCATION).getValue();
                consumeResponse(response);

                HttpGet idpHttpGet = new HttpGet(initialHttpGetRedirect); // GET /idp-test-DEP1/?SAMLRequest=jZLfT4MwEMf.....
                idpHttpGet.setParams(doNotRedirect);
                response = httpClient.execute(idpHttpGet);
                assertThat("Unexpected status code when expecting redirect from SP with SAML request", response.getStatusLine()
                        .getStatusCode(), equalTo(HttpStatus.SC_MOVED_TEMPORARILY));
                String idpHttpGetRedirect = response.getFirstHeader(HttpHeaders.LOCATION).getValue();
                consumeResponse(response);

                HttpGet idpHttpGetRedirectForAuth = new HttpGet(idpHttpGetRedirect); // GET
                // /idp-test-DEP1/?SAMLRequest=jZLfT4MwEMf.....,
                // Authorization: Negotiate
                idpHttpGetRedirectForAuth.setParams(doNotRedirect);
                response = httpClient.execute(idpHttpGetRedirectForAuth);
                assertThat("Unexpected status code when expecting redirect from IdP with SAML response", response
                        .getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_MOVED_TEMPORARILY));
                String idpHttpGetRedirectAuth = response.getFirstHeader(HttpHeaders.LOCATION).getValue();
                consumeResponse(response);

                HttpGet spHttpGet = new HttpGet(idpHttpGetRedirectAuth); // GET /test-DEP1/?SAMLResponse=...
                spHttpGet.setParams(doNotRedirect);
                response = httpClient.execute(spHttpGet);
                assertThat("Unexpected status code when expecting succesfull authentication to the SP", response
                        .getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_OK));
                return EntityUtils.toString(response.getEntity());
            }
        }
    }

}
