/*
 * ModeShape (http://www.modeshape.org)
 *
 * 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.
 */
package org.modeshape.jcr;

import static org.hamcrest.core.Is.is;
import static org.hamcrest.core.IsNull.notNullValue;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.security.AccessControlContext;
import java.security.AccessController;
import java.security.PrivilegedExceptionAction;
import java.util.HashMap;
import java.util.Map;
import javax.jcr.Credentials;
import javax.jcr.LoginException;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.SimpleCredentials;
import javax.security.auth.Subject;
import javax.security.auth.login.LoginContext;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import org.modeshape.common.FixFor;
import org.modeshape.common.junit.SkipLongRunning;
import org.modeshape.common.junit.SkipTestRule;
import org.modeshape.jcr.RepositoryConfiguration.FieldName;
import org.modeshape.jcr.security.AdvancedAuthorizationProvider;
import org.modeshape.jcr.security.AuthenticationProvider;
import org.modeshape.jcr.security.AuthorizationProvider;
import org.modeshape.jcr.security.JaasSecurityContext.UserPasswordCallbackHandler;
import org.modeshape.jcr.security.SecurityContext;
import org.modeshape.jcr.value.Path;
import org.modeshape.jcr.value.StringFactory;
import org.modeshape.schematic.Schematic;
import org.modeshape.schematic.document.Document;
import org.modeshape.schematic.document.EditableArray;
import org.modeshape.schematic.document.EditableDocument;

public class AuthenticationAndAuthorizationTest {

    @Rule
    public TestRule skipTestRule = new SkipTestRule();

    private static final String REPO_NAME = "testRepo";

    @BeforeClass
    public static void beforeAll() {
        // Initialize PicketBox ...
        JaasTestUtil.initJaas("security/jaas.conf.xml");
    }

    protected JcrRepository repository;
    protected JcrSession session;
    
    @After
    public void afterEach() throws Exception {
        if (repository != null) {
            try {
                TestingUtil.killRepositories(repository);
            } finally {
                repository = null;
                session = null;
            }
        }
    }

    /**
     * Start the repository using the supplied repository configuration. Note that this does <i>not</i> create a session.
     * 
     * @param doc the document containing the configuration; may not be null
     * @param repoName the name of the repository; may not be null
     * @throws Exception if there is a problem starting the repository
     */
    protected void startRepositoryWith( Document doc,
                                        String repoName ) throws Exception {
        RepositoryConfiguration config = new RepositoryConfiguration(doc, repoName, new TestingEnvironment());
        repository = new JcrRepository(config);
        repository.start();
    }

    /**
     * Creates a repository configuration that uses the specified JAAS provider and optionally enables anonymous logins.
     * <p>
     * The configuration for "repositoryName" as the repository name, anonymous logins disabled, and "modeshape-jcr" as the JAAS
     * policy looks as follows:
     * 
     * <pre>
     * {
     *     "name" : "repositoryNameParameter";
     *     "security" : {
     *         "anonymous" : {
     *             "roles" : []
     *         },
     *         "providers" : [
     *             {
     *                 "classname" : "JAAS",
     *                 "policyName" : "modeshape-jcr"
     *             }
     *         ]
     *     }
     * }
     * </pre>
     * 
     * If anonymous logins <i>are</i> enabled, then they are also enabled on failed logins:
     * 
     * <pre>
     * {
     *     "name" : "repositoryNameParameter";
     *     "security" : {
     *         "anonymous" : {
     *             "useOnFailedLogin" : true
     *         },
     *         "providers" : [
     *             {
     *                 "classname" : "JAAS",
     *                 "policyName" : "modeshape-jcr"
     *             }
     *         ]
     *     }
     * }
     * </pre>
     * 
     * </p>
     * 
     * @param repositoryName the name of the repository; may not be null
     * @param jaasPolicyName the name of the jaas policy; may be null if JAAS should be not be enabled
     * @param anonymousRoleNames the anonymous role names, or empty if anonymous logins should be disabled
     * @return the configuration document; never null
     */
    protected Document createRepositoryConfiguration( String repositoryName,
                                                      String jaasPolicyName,
                                                      String... anonymousRoleNames ) {
        EditableDocument doc = Schematic.newDocument("name", repositoryName);
        EditableDocument security = doc.getOrCreateDocument("security");

        if (anonymousRoleNames == null || anonymousRoleNames.length == 0) {
            // Disable anonymous logins ...
            EditableDocument anonymous = security.getOrCreateDocument("anonymous");
            anonymous.setArray("roles");
        } else {
            // Set the roles and use on failed logins ...
            EditableDocument anonymous = security.getOrCreateDocument("anonymous");
            anonymous.setArray("roles", (Object[])anonymousRoleNames);
            anonymous.setBoolean("useOnFailedLogin", true);
        }

        if (jaasPolicyName != null) {
            // Add the JAAS provider ...
            EditableArray providers = security.getOrCreateArray("providers");
            EditableDocument jaas = Schematic.newDocument(FieldName.CLASSNAME, "JAAS", "policyName", "modeshape-jcr");
            providers.addDocument(jaas);
        }

        return doc;
    }

    @Test
    public void shouldLogInAsAnonymousUsingNoCredentials() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr-non-existant";
        String[] anonRoleNames = {ModeShapeRoles.READWRITE};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        session = repository.login();
        session.getRootNode().getPath();
        session.getRootNode().addNode("someNewNode");
    }

    @Test
    public void shouldLogInAsAnonymousWithReadOnlyPrivilegesUsingNoCredentials() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr-non-existant";
        String[] anonRoleNames = {ModeShapeRoles.READONLY};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        session = repository.login();
        session.getRootNode().getPath();
        try {
            session.getRootNode().addNode("someNewNode");
            fail("Should not have been able to update content with a read-only user");
        } catch (javax.jcr.AccessDeniedException e) {
            // expected
        }
    }

    @Test
    public void shouldLogInAsUserWithReadOnlyRole() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        session = repository.login(new SimpleCredentials(ModeShapeRoles.READONLY, ModeShapeRoles.READONLY.toCharArray()));

        session.getRootNode().getPath();
        session.getRootNode().getDefinition();
        try {
            session.getRootNode().addNode("someNewNode");
            fail("Should not have been able to update content with a read-only user");
        } catch (javax.jcr.AccessDeniedException e) {
            // expected
        }
    }

    @Test
    public void shouldLogInAsUserWithReadWriteRole() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        session = repository.login(new SimpleCredentials("readwrite", "readwrite".toCharArray()));

        session.getRootNode().getPath();
        session.getRootNode().getDefinition();
        session.getRootNode().addNode("someNewNode");
    }

    @Test
    public void shouldNotAllowAnonymousLoginsWhenUsingOnlyJaas() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        try {
            session = repository.login();
            fail("Should not have been able to login anonymously if anonymous logins are disabled");
        } catch (LoginException e) {
            // expected
        }
    }

    @Test
    public void shouldLogInAsAnonymousUserIfNoProviderAuthenticatesCredentials() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {ModeShapeRoles.READONLY};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        session = repository.login(new SimpleCredentials("readwrite", "wrongpassword".toCharArray()));

        assertThat(session.isAnonymous(), is(true));

        session.getRootNode().getPath();
        session.getRootNode().getDefinition();
        try {
            session.getRootNode().addNode("someNewNode");
            fail("Should not have been able to update content with a read-only user");
        } catch (javax.jcr.AccessDeniedException e) {
            // expected
        }
    }

    @Test
    public void shouldLogInAsWritableAnonymousUserIfNoProviderAuthenticatesCredentials() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {ModeShapeRoles.READWRITE};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        session = repository.login(new SimpleCredentials("readwrite", "wrongpassword".toCharArray()));

        assertThat(session.isAnonymous(), is(true));

        session.getRootNode().getPath();
        session.getRootNode().getDefinition();
        session.getRootNode().addNode("someNewNode");
    }

    @SuppressWarnings( "cast" )
    @Test
    public void shouldAllowLoginWithNoCredentialsInPrivilegedBlock() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {ModeShapeRoles.READWRITE};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);
        startRepositoryWith(config, repoName);

        // Verify the JAAS was configured correctly ...
        session = repository.login(new SimpleCredentials("readwrite", "readwrite".toCharArray()));

        LoginContext login = new LoginContext("modeshape-jcr", new UserPasswordCallbackHandler("superuser",
                                                                                               "superuser".toCharArray()));
        login.login();

        Subject subject = login.getSubject();

        Session session = (Session)Subject.doAsPrivileged(subject, new PrivilegedExceptionAction<Session>() {

            @Override
            public Session run() throws Exception {
                return repository.login();
            }

        }, AccessController.getContext());

        assertThat(session, is(notNullValue()));
        assertThat(session.getUserID(), is("superuser"));
        login.logout();
    }

    @Test( expected = javax.jcr.LoginException.class )
    public void shouldNotAllowLoginIfCredentialsDoNotProvideJaasMethod() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        repository.login(new Credentials() {
            private static final long serialVersionUID = 1L;
        });
    }

    @Test( expected = javax.jcr.LoginException.class )
    public void shouldNotAllowLoginIfCredentialsReturnNullAccessControlContext() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        repository.login(new Credentials() {
            private static final long serialVersionUID = 1L;

            @SuppressWarnings( "unused" )
            public AccessControlContext getAccessControlContext() {
                return null;
            }
        });
    }

    @Test( expected = javax.jcr.LoginException.class )
    public void shouldNotAllowLoginIfCredentialsReturnNullLoginContext() throws Exception {
        String repoName = REPO_NAME;
        String jaasPolicyName = "modeshape-jcr";
        String[] anonRoleNames = {};
        Document config = createRepositoryConfiguration(repoName, jaasPolicyName, anonRoleNames);

        startRepositoryWith(config, repoName);

        repository.login(new Credentials() {

            private static final long serialVersionUID = 1L;

            @SuppressWarnings( "unused" )
            public LoginContext getLoginContext() {
                return null;
            }
        });
    }

    @Test
    @FixFor( "MODE-1938" )
    @SkipLongRunning
    public void shouldNotLeakUninitializedWorkspaceCaches() throws Exception {
        int runCount = 200;
        for (int i = 0; i < runCount; i++) {
            shouldLogInAsAnonymousUserIfNoProviderAuthenticatesCredentials();
            afterEach();
        }
    }

    @Test
    @FixFor( "MODE-2225" )
    public void shouldInvokeAuthorizationProviderWhenIteratingNodes() throws Exception {
        //this will import an initial structure of nodes (see xmlImport/docWithChildren.xml)
        repository = TestingUtil.startRepositoryWithConfig("config/custom-authentication-provider-config-1.json");
        assertPermissionsCheckedWhenIteratingChildren();
    }

    @Test
    @FixFor( "MODE-2225" )
    public void shouldInvokeAdvancedAuthorizationProviderWhenIteratingNodes() throws Exception {
        //this will import an initial structure of nodes (see xmlImport/docWithChildren.xml)
        repository = TestingUtil.startRepositoryWithConfig("config/custom-authentication-provider-config-2.json");
        assertPermissionsCheckedWhenIteratingChildren();
    }

    private void assertPermissionsCheckedWhenIteratingChildren() throws RepositoryException {
        session = repository.login();
        Node testRoot = session.getNode("/testRoot");
        NodeIterator children = testRoot.getNodes();
        while (children.hasNext()) {
            children.nextNode();
        }
        TestSecurityProvider provider = (TestSecurityProvider) session.context().getSecurityContext();

        Map<String, String> actionsByNodePath = provider.getActionsByNodePath();
        assertTrue("READ permission not checked for node", actionsByNodePath.containsKey("/testRoot"));
        assertTrue("READ permission not checked for node", actionsByNodePath.containsKey("/testRoot/node3"));
        assertTrue("READ permission not checked for node", actionsByNodePath.containsKey("/testRoot/node2"));
        assertTrue("READ permission not checked for node", actionsByNodePath.containsKey("/testRoot/node1"));
    }

    public static abstract class TestSecurityProvider implements AuthenticationProvider, SecurityContext {
        protected final Map<String, String> actionsByNodePath = new HashMap<>();
        protected StringFactory stringFactory;

        @Override
        public ExecutionContext authenticate( Credentials credentials, String repositoryName, String workspaceName,
                                              ExecutionContext repositoryContext, Map<String, Object> sessionAttributes ) {
            stringFactory = repositoryContext.getValueFactories().getStringFactory();
            return repositoryContext.with(this);
        }

        @Override
        public boolean isAnonymous() {
            return false;
        }

        @Override
        public String getUserName() {
            return "test user";
        }

        @Override
        public boolean hasRole( String roleName ) {
            return true;
        }

        @Override
        public void logout() {
        }

        public Map<String, String> getActionsByNodePath() {
            return actionsByNodePath;
        }
    }

    public static class SimpleTestSecurityProvider extends TestSecurityProvider implements AuthorizationProvider {
        @Override
        public boolean hasPermission( ExecutionContext context, String repositoryName, String repositorySourceName,
                                      String workspaceName, Path absPath, String... actions ) {
            actionsByNodePath.put(stringFactory.create(absPath), actions[0]);
            return true;
        }

    }

    public static class AdvancedTestSecurityProvider extends TestSecurityProvider implements AdvancedAuthorizationProvider {
        @Override
        public boolean hasPermission( Context context, Path absPath, String... actions ) {
            actionsByNodePath.put(stringFactory.create(absPath), actions[0]);
            return true;
        }
    }
}
