/*
 *  Licensed to the Apache Software Foundation (ASF) under one or more
 *  contributor license agreements.  See the NOTICE file distributed with
 *  this work for additional information regarding copyright ownership.
 *  The ASF licenses this file to You 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.apache.catalina.authenticator;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import jakarta.servlet.http.HttpServletResponse;

import org.junit.Assert;
import org.junit.Test;

import org.apache.catalina.Context;
import org.apache.catalina.session.ManagerBase;
import org.apache.catalina.startup.TesterServlet;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.startup.TomcatBaseTest;
import org.apache.tomcat.util.buf.ByteChunk;
import org.apache.tomcat.util.descriptor.web.LoginConfig;
import org.apache.tomcat.util.descriptor.web.SecurityCollection;
import org.apache.tomcat.util.descriptor.web.SecurityConstraint;

/**
 * Test BasicAuthenticator and NonLoginAuthenticator when a SingleSignOn Valve is not active.
 * <p>
 * In the absence of SSO support, these two authenticator classes both have quite simple behaviour. By testing them
 * together, we can make sure they operate independently and confirm that no SSO logic has been accidentally triggered.
 * <p>
 * r1495169 refactored BasicAuthenticator by creating an inner class called BasicCredentials. All edge cases associated
 * with strangely encoded Base64 credentials are tested thoroughly by TestBasicAuthParser. Therefore,
 * TestNonLoginAndBasicAuthenticator only needs to examine a sufficient set of test cases to verify the interface
 * between BasicAuthenticator and BasicCredentials, which it does by running each test under a separate tomcat instance.
 */
public class TestNonLoginAndBasicAuthenticator extends TomcatBaseTest {

    protected static final boolean USE_COOKIES = true;
    protected static final boolean NO_COOKIES = !USE_COOKIES;

    private static final String USER = "user";
    private static final String PWD = "pwd";
    private static final String ROLE = "role";
    private static final String NICE_METHOD = "Basic";

    private static final String HTTP_PREFIX = "http://localhost:";
    private static final String CONTEXT_PATH_NOLOGIN = "/nologin";
    private static final String CONTEXT_PATH_LOGIN = "/login";
    private static final String URI_PROTECTED = "/protected";
    private static final String URI_PUBLIC = "/anyoneCanAccess";

    private static final int SHORT_SESSION_TIMEOUT_SECS = 1;
    private static final int MANAGER_SCAN_INTERVAL_SECS = 2;
    private static final int MANAGER_EXPIRE_SESSIONS_FAST = 1;
    private static final int EXTRA_DELAY_SECS = 5;
    private static final long TIMEOUT_DELAY_MSECS = ((SHORT_SESSION_TIMEOUT_SECS +
            (MANAGER_SCAN_INTERVAL_SECS * MANAGER_EXPIRE_SESSIONS_FAST) + EXTRA_DELAY_SECS) * 1000);

    private static final String CLIENT_AUTH_HEADER = "authorization";
    private static final String SERVER_AUTH_HEADER = "WWW-Authenticate";
    private static final String SERVER_COOKIE_HEADER = "Set-Cookie";
    private static final String CLIENT_COOKIE_HEADER = "Cookie";

    private static final BasicCredentials NO_CREDENTIALS = null;
    private static final BasicCredentials GOOD_CREDENTIALS = new BasicCredentials(NICE_METHOD, USER, PWD);
    private static final BasicCredentials STRANGE_CREDENTIALS = new BasicCredentials("bAsIc", USER, PWD);
    private static final BasicCredentials BAD_CREDENTIALS = new BasicCredentials(NICE_METHOD, USER, "wrong");
    private static final BasicCredentials BAD_METHOD = new BasicCredentials("BadMethod", USER, PWD);

    private Tomcat tomcat;
    private Context basicContext;
    private Context nonloginContext;
    private List<String> cookies;

    /*
     * Try to access an unprotected resource in a webapp that does not have a login method defined. This should be
     * permitted.
     */
    @Test
    public void testAcceptPublicNonLogin() throws Exception {
        doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PUBLIC, NO_COOKIES, HttpServletResponse.SC_OK);
    }

    /*
     * Try to access a protected resource in a webapp that does not have a login method defined. This should be rejected
     * with SC_FORBIDDEN 403 status.
     */
    @Test
    public void testRejectProtectedNonLogin() throws Exception {
        doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PROTECTED, NO_COOKIES, HttpServletResponse.SC_FORBIDDEN);
    }

    /*
     * Try to access an unprotected resource in a webapp that has a BASIC login method defined. This should be permitted
     * without a challenge.
     */
    @Test
    public void testAcceptPublicBasic() throws Exception {
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PUBLIC, NO_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);
    }

    /*
     * Try to access a protected resource in a webapp that has a BASIC login method defined. The access will be
     * challenged with 401 SC_UNAUTHORIZED, and then be permitted once authenticated.
     */
    @Test
    public void testAcceptProtectedBasic() throws Exception {
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);
    }

    /*
     * This is the same as testAcceptProtectedBasic (above), except using an invalid password.
     */
    @Test
    public void testAuthMethodBadCredentials() throws Exception {
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, BAD_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
    }

    /*
     * This is the same as testAcceptProtectedBasic (above), except to verify the server follows RFC2617 by treating the
     * auth-scheme token as case-insensitive.
     */
    @Test
    public void testAuthMethodCaseBasic() throws Exception {
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, STRANGE_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);
    }

    /*
     * This is the same as testAcceptProtectedBasic (above), except using an invalid authentication method.
     *
     * Note: the container ensures the Basic login method is called. BasicAuthenticator does not find the expected
     * authentication header method, and so does not extract any credentials.
     *
     * The request is rejected with 401 SC_UNAUTHORIZED status. RFC2616 says the response body should identify the
     * auth-schemes that are acceptable for the container.
     */
    @Test
    public void testAuthMethodBadMethod() throws Exception {
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, BAD_METHOD, NO_COOKIES, HttpServletResponse.SC_UNAUTHORIZED);
    }

    /*
     * The default behaviour of BASIC authentication does NOT create a session on the server. Verify that the client is
     * required to send a valid authenticate header with every request to access protected resources.
     */
    @Test
    public void testBasicLoginWithoutSession() throws Exception {

        // this section is identical to testAuthMethodCaseBasic
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);

        // next, try to access the protected resource while not providing
        // credentials. This confirms the server has not retained any state
        // data which might allow it to authenticate the client. Expect
        // to be challenged with 401 SC_UNAUTHORIZED.
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);

        // finally, provide credentials to confirm the resource
        // can still be accessed with an authentication header.
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);
    }

    /*
     * Test the optional behaviour of BASIC authentication to create a session on the server. The server will return a
     * session cookie.
     *
     * 1. try to access a protected resource without credentials, so get Unauthorized status. 2. try to access a
     * protected resource when providing credentials, so get OK status and a server session cookie. 3. access the
     * protected resource once more using a session cookie. 4. repeat using the session cookie.
     *
     * Note: The FormAuthenticator is a two-step process and is protected from session fixation attacks by the default
     * AuthenticatorBase changeSessionIdOnAuthentication setting of true. However, BasicAuthenticator is a one-step
     * process and so the AuthenticatorBase does not reissue the sessionId.
     */
    @Test
    public void testBasicLoginSessionPersistence() throws Exception {

        setAlwaysUseSession();

        // this section is identical to testAuthMethodCaseBasic
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);

        // confirm the session is not recognised by the server alone
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);

        // now provide the harvested session cookie for authentication
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, USE_COOKIES, HttpServletResponse.SC_OK);

        // finally, do it again with the cookie to be sure
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, USE_COOKIES, HttpServletResponse.SC_OK);
    }

    /*
     * Verify the timeout mechanism works for BASIC sessions. This test follows the flow of
     * testBasicLoginSessionPersistence (above).
     */
    @Test
    public void testBasicLoginSessionTimeout() throws Exception {

        setAlwaysUseSession();
        setRapidSessionTimeout();

        // this section is identical to testAuthMethodCaseBasic
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);

        // now provide the harvested session cookie for authentication
        List<String> originalCookies = cookies;
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, USE_COOKIES, HttpServletResponse.SC_OK);

        // Force session to expire one second from now
        forceSessionMaxInactiveInterval((Context) getTomcatInstance().getHost().findChild(CONTEXT_PATH_LOGIN),
                SHORT_SESSION_TIMEOUT_SECS);

        // allow the session to time out and lose authentication
        Thread.sleep(TIMEOUT_DELAY_MSECS);

        // provide the harvested session cookie for authentication
        // to confirm it has expired
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, USE_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);

        // finally, do BASIC reauthentication and get another session
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);

        // slightly paranoid verification
        boolean sameCookies = originalCookies.equals(cookies);
        Assert.assertTrue(!sameCookies);
    }

    /*
     * Logon to access a protected resource in a webapp that uses BASIC authentication. Then try to access a protected
     * resource in a different webapp which does not have a login method. This should be rejected with SC_FORBIDDEN 403
     * status, confirming there has been no cross-authentication between the webapps.
     */
    @Test
    public void testBasicLoginRejectProtected() throws Exception {
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);

        doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PROTECTED, NO_COOKIES, HttpServletResponse.SC_FORBIDDEN);
    }

    /*
     * Try to use the session cookie from the BASIC webapp to request access to the webapp that does not have a login
     * method. (This is equivalent to Single Signon, but without the Valve.)
     *
     * Verify there is no cross-authentication when using similar logic to testBasicLoginRejectProtected (above).
     *
     * This should be rejected with SC_FORBIDDEN 403 status.
     */
    @Test
    public void testBasicLoginRejectProtectedWithSession() throws Exception {

        setAlwaysUseSession();

        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, NO_CREDENTIALS, NO_COOKIES,
                HttpServletResponse.SC_UNAUTHORIZED);
        doTestBasic(CONTEXT_PATH_LOGIN + URI_PROTECTED, GOOD_CREDENTIALS, NO_COOKIES, HttpServletResponse.SC_OK);

        // use the session cookie harvested with the other webapp
        doTestNonLogin(CONTEXT_PATH_NOLOGIN + URI_PROTECTED, USE_COOKIES, HttpServletResponse.SC_FORBIDDEN);
    }


    private void doTestNonLogin(String uri, boolean useCookie, int expectedRC) throws Exception {

        Map<String,List<String>> reqHeaders = new HashMap<>();
        Map<String,List<String>> respHeaders = new HashMap<>();

        if (useCookie) {
            addCookies(reqHeaders);
        }

        ByteChunk bc = new ByteChunk();
        int rc = getUrl(HTTP_PREFIX + getPort() + uri, bc, reqHeaders, respHeaders);

        if (expectedRC != HttpServletResponse.SC_OK) {
            Assert.assertEquals(expectedRC, rc);
            Assert.assertTrue(bc.getLength() > 0);
        } else {
            Assert.assertEquals("OK", bc.toString());
        }
    }

    private void doTestBasic(String uri, BasicCredentials credentials, boolean useCookie, int expectedRC)
            throws Exception {

        Map<String,List<String>> reqHeaders = new HashMap<>();
        Map<String,List<String>> respHeaders = new HashMap<>();

        if (useCookie) {
            addCookies(reqHeaders);
        } else {
            if (credentials != null) {
                List<String> auth = new ArrayList<>();
                auth.add(credentials.getCredentials());
                reqHeaders.put(CLIENT_AUTH_HEADER, auth);
            }
        }

        ByteChunk bc = new ByteChunk();
        int rc = getUrl(HTTP_PREFIX + getPort() + uri, bc, reqHeaders, respHeaders);

        if (expectedRC != HttpServletResponse.SC_OK) {
            Assert.assertEquals(expectedRC, rc);
            Assert.assertTrue(bc.getLength() > 0);
            if (expectedRC == HttpServletResponse.SC_UNAUTHORIZED) {
                // The server should identify the acceptable method(s)
                boolean methodFound = false;
                List<String> authHeaders = respHeaders.get(SERVER_AUTH_HEADER);
                for (String authHeader : authHeaders) {
                    if (authHeader.contains(NICE_METHOD)) {
                        methodFound = true;
                        break;
                    }
                }
                Assert.assertTrue(methodFound);
            }
        } else {
            Assert.assertEquals("OK", bc.toString());
            List<String> newCookies = respHeaders.get(SERVER_COOKIE_HEADER);
            if (newCookies != null) {
                // harvest cookies whenever the server sends some new ones
                saveCookies(respHeaders);
            }
        }
    }


    /*
     * setup two webapps for every test
     *
     * note: the super class tearDown method will stop tomcat
     */
    @Override
    public void setUp() throws Exception {

        super.setUp();

        // create a tomcat server using the default in-memory Realm
        tomcat = getTomcatInstance();

        // add the test user and role to the Realm
        tomcat.addUser(USER, PWD);
        tomcat.addRole(USER, ROLE);

        // setup both NonLogin and Login webapps
        setUpNonLogin();
        setUpLogin();

        tomcat.start();
    }


    private void setUpNonLogin() throws Exception {

        // Must have a real docBase for webapps - just use temp
        nonloginContext = tomcat.addContext(CONTEXT_PATH_NOLOGIN, System.getProperty("java.io.tmpdir"));

        // Add protected servlet to the context
        Tomcat.addServlet(nonloginContext, "TesterServlet1", new TesterServlet());
        nonloginContext.addServletMappingDecoded(URI_PROTECTED, "TesterServlet1");

        SecurityCollection collection1 = new SecurityCollection();
        collection1.addPatternDecoded(URI_PROTECTED);
        SecurityConstraint sc1 = new SecurityConstraint();
        sc1.addAuthRole(ROLE);
        sc1.addCollection(collection1);
        nonloginContext.addConstraint(sc1);

        // Add unprotected servlet to the context
        Tomcat.addServlet(nonloginContext, "TesterServlet2", new TesterServlet());
        nonloginContext.addServletMappingDecoded(URI_PUBLIC, "TesterServlet2");

        SecurityCollection collection2 = new SecurityCollection();
        collection2.addPatternDecoded(URI_PUBLIC);
        SecurityConstraint sc2 = new SecurityConstraint();
        // do not add a role - which signals access permitted without one
        sc2.addCollection(collection2);
        nonloginContext.addConstraint(sc2);

        // Configure the authenticator and inherit the Realm from Engine
        LoginConfig lc = new LoginConfig();
        lc.setAuthMethod("NONE");
        nonloginContext.setLoginConfig(lc);
        AuthenticatorBase nonloginAuthenticator = new NonLoginAuthenticator();
        nonloginContext.getPipeline().addValve(nonloginAuthenticator);
    }

    private void setUpLogin() throws Exception {

        // Must have a real docBase for webapps - just use temp
        basicContext = tomcat.addContext(CONTEXT_PATH_LOGIN, System.getProperty("java.io.tmpdir"));

        // Add protected servlet to the context
        Tomcat.addServlet(basicContext, "TesterServlet3", new TesterServlet());
        basicContext.addServletMappingDecoded(URI_PROTECTED, "TesterServlet3");
        SecurityCollection collection = new SecurityCollection();
        collection.addPatternDecoded(URI_PROTECTED);
        SecurityConstraint sc = new SecurityConstraint();
        sc.addAuthRole(ROLE);
        sc.addCollection(collection);
        basicContext.addConstraint(sc);

        // Add unprotected servlet to the context
        Tomcat.addServlet(basicContext, "TesterServlet4", new TesterServlet());
        basicContext.addServletMappingDecoded(URI_PUBLIC, "TesterServlet4");

        SecurityCollection collection2 = new SecurityCollection();
        collection2.addPatternDecoded(URI_PUBLIC);
        SecurityConstraint sc2 = new SecurityConstraint();
        // do not add a role - which signals access permitted without one
        sc2.addCollection(collection2);
        basicContext.addConstraint(sc2);

        // Configure the authenticator and inherit the Realm from Engine
        LoginConfig lc = new LoginConfig();
        lc.setAuthMethod("BASIC");
        basicContext.setLoginConfig(lc);
        AuthenticatorBase basicAuthenticator = new BasicAuthenticator();
        basicContext.getPipeline().addValve(basicAuthenticator);
    }

    /*
     * Force non-default behaviour for both Authenticators
     */
    private void setAlwaysUseSession() {

        ((AuthenticatorBase) basicContext.getAuthenticator()).setAlwaysUseSession(true);
        ((AuthenticatorBase) nonloginContext.getAuthenticator()).setAlwaysUseSession(true);
    }

    /*
     * Force rapid timeout scanning for the Basic Authentication webapp The StandardManager default service cycle time
     * is 10 seconds, with a session expiry scan every 6 cycles.
     */
    private void setRapidSessionTimeout() {
        basicContext.getParent().getParent().setBackgroundProcessorDelay(MANAGER_SCAN_INTERVAL_SECS);
        ((ManagerBase) basicContext.getManager()).setProcessExpiresFrequency(MANAGER_EXPIRE_SESSIONS_FAST);
    }

    /*
     * Encapsulate the logic to generate an HTTP header for BASIC Authentication. Note: only used internally, so no need
     * to validate arguments.
     */
    private static final class BasicCredentials {

        private final String method;
        private final String username;
        private final String password;
        private final String credentials;

        private BasicCredentials(String aMethod, String aUsername, String aPassword) {
            method = aMethod;
            username = aUsername;
            password = aPassword;
            String userCredentials = username + ":" + password;
            byte[] credentialsBytes = userCredentials.getBytes(StandardCharsets.ISO_8859_1);
            String base64auth = Base64.getEncoder().encodeToString(credentialsBytes);
            credentials = method + " " + base64auth;
        }

        private String getCredentials() {
            return credentials;
        }
    }

    /*
     * extract and save the server cookies from the incoming response
     */
    protected void saveCookies(Map<String,List<String>> respHeaders) {
        // we only save the Cookie values, not header prefix
        List<String> cookieHeaders = respHeaders.get(SERVER_COOKIE_HEADER);
        if (cookieHeaders == null) {
            cookies = null;
        } else {
            cookies = new ArrayList<>(cookieHeaders.size());
            for (String cookieHeader : cookieHeaders) {
                cookies.add(cookieHeader.substring(0, cookieHeader.indexOf(';')));
            }
        }
    }

    /*
     * add all saved cookies to the outgoing request
     */
    protected void addCookies(Map<String,List<String>> reqHeaders) {
        if ((cookies != null) && (cookies.size() > 0)) {
            StringBuilder cookieHeader = new StringBuilder();
            boolean first = true;
            for (String cookie : cookies) {
                if (!first) {
                    cookieHeader.append(';');
                } else {
                    first = false;
                }
                cookieHeader.append(cookie);
            }
            List<String> cookieHeaderList = new ArrayList<>(1);
            cookieHeaderList.add(cookieHeader.toString());
            reqHeaders.put(CLIENT_COOKIE_HEADER, cookieHeaderList);
        }
    }
}