TestRouterAuthentication.java

/*
 * 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 com.facebook.presto.router;

import com.facebook.airlift.bootstrap.Bootstrap;
import com.facebook.airlift.bootstrap.LifeCycleManager;
import com.facebook.airlift.http.client.HttpClient;
import com.facebook.airlift.http.client.HttpClientConfig;
import com.facebook.airlift.http.client.Request;
import com.facebook.airlift.http.client.Response;
import com.facebook.airlift.http.client.ResponseHandler;
import com.facebook.airlift.http.client.jetty.JettyHttpClient;
import com.facebook.airlift.http.server.HttpServerInfo;
import com.facebook.airlift.http.server.testing.TestingHttpServerModule;
import com.facebook.airlift.jaxrs.JaxrsModule;
import com.facebook.airlift.json.JsonModule;
import com.facebook.airlift.log.Logger;
import com.facebook.airlift.log.Logging;
import com.facebook.airlift.node.testing.TestingNodeModule;
import com.facebook.presto.password.file.FileAuthenticatorFactory;
import com.facebook.presto.router.security.RouterSecurityModule;
import com.facebook.presto.server.security.PasswordAuthenticatorManager;
import com.facebook.presto.spi.security.PasswordAuthenticatorFactory;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.io.ByteStreams;
import com.google.inject.Injector;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

import javax.ws.rs.core.UriBuilder;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Base64;
import java.util.Optional;

import static com.facebook.presto.router.TestingRouterUtil.createPrestoServer;
import static com.facebook.presto.router.TestingRouterUtil.getConfigFile;
import static java.lang.String.format;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.testng.Assert.assertEquals;

@Test(singleThreaded = true)
public class TestRouterAuthentication
{
    private static final Logger log = Logger.get(TestRouterAuthentication.class);
    private static final String jksPassword = "testPass";

    private File configFile;
    private LifeCycleManager lifeCycleManager;
    private Path authenticatorPropertiesFile;
    private HttpServerInfo httpServerInfo;
    private Path storePath;
    private Path keystorePath;
    private Path truststorePath;
    private HttpClient httpClient;

    private void runKeytoolCommand(String command, Object... args)
            throws IOException, InterruptedException
    {
        String cmd = format(command, args);
        Process process = new ProcessBuilder()
                .directory(storePath.toFile())
                .command(Splitter.on(" ").splitToStream(cmd).toArray(String[]::new))
                .start();
        process.waitFor();
        if (log.isDebugEnabled()) {
            log.debug("running command: " + cmd + ". Output:\n" + new String(ByteStreams.toByteArray(process.getInputStream())));
        }
    }

    private void setupCertStores()
            throws Exception
    {
        runKeytoolCommand("keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -validity 365 -keystore keystore.jks -storepass %s -keypass %s -dname CN=localhost -ext SAN=ip:127.0.0.1", jksPassword, jksPassword);
        runKeytoolCommand("keytool -exportcert -alias localhost -keystore keystore.jks -file certificate.cer -storepass %s", jksPassword);
        runKeytoolCommand("keytool -importcert -alias localhost -file certificate.cer -keystore truststore.jks -storepass %s -noprompt", jksPassword);
        keystorePath = storePath.resolve("keystore.jks");
        truststorePath = storePath.resolve("truststore.jks");
    }

    @BeforeClass
    public void setup()
            throws Exception
    {
        Logging.initialize();
        Path tempFile = Files.createTempFile("temp-config", ".json");
        configFile = getConfigFile(ImmutableList.of(createPrestoServer()), tempFile.toFile());

        authenticatorPropertiesFile = Paths.get("etc/password-authenticator.properties");
        authenticatorPropertiesFile.getParent().toFile().mkdirs();

        Path passwordFilePath = Files.createTempFile("passwords", ".db");
        // credentials come from htpasswd -n -B -C 8, input "testpass"
        Files.write(passwordFilePath, "testuser:$2y$08$KBfSimK6KTZFyCKlJACpTu7VMBHlnFixXm8tDh9I0rDf3IIuobtHy".getBytes(UTF_8));
        Files.write(
                authenticatorPropertiesFile,
                format("password-authenticator.name=file\nfile.password-file=%s", passwordFilePath).getBytes(UTF_8),
                StandardOpenOption.CREATE);
        storePath = Files.createTempDirectory("jks-store");
        setupCertStores();

        Bootstrap app = new Bootstrap(
                new TestingNodeModule("test"),
                new TestingHttpServerModule(),
                new JsonModule(),
                new JaxrsModule(true),
                new RouterSecurityModule(),
                new RouterModule(Optional.empty()));

        Injector injector = app.doNotInitializeLogging()
                .setRequiredConfigurationProperty("router.config-file", configFile.getAbsolutePath())
                .setRequiredConfigurationProperty("presto.version", "test")
                .setOptionalConfigurationProperty("http-server.authentication.type", "PASSWORD")
                .setOptionalConfigurationProperty("http-server.http.enabled", "false")
                .setOptionalConfigurationProperty("http-server.https.enabled", "true")
                .setOptionalConfigurationProperty("http-server.https.keystore.path", keystorePath.toAbsolutePath().toString())
                .setOptionalConfigurationProperty("http-server.https.keystore.key", jksPassword)
                .initialize();
        injector.getInstance(RouterPluginManager.class).loadPlugins();

        PasswordAuthenticatorManager passwordAuthenticatorManager = injector.getInstance(PasswordAuthenticatorManager.class);
        PasswordAuthenticatorFactory authFactory = new FileAuthenticatorFactory();
        passwordAuthenticatorManager.addPasswordAuthenticatorFactory(authFactory);
        passwordAuthenticatorManager.setRequired();
        passwordAuthenticatorManager.loadPasswordAuthenticator();

        lifeCycleManager = injector.getInstance(LifeCycleManager.class);
        httpServerInfo = injector.getInstance(HttpServerInfo.class);
        httpClient = new JettyHttpClient(
                new HttpClientConfig()
                        .setTrustStorePath(truststorePath.toAbsolutePath().toString())
                        .setTrustStorePassword(jksPassword));
    }

    @AfterClass(alwaysRun = true)
    public void tearDownServer()
    {
        if (authenticatorPropertiesFile != null) {
            try {
                authenticatorPropertiesFile.toFile().delete();
            }
            catch (Exception e) {
                // ignore
            }
        }
        if (lifeCycleManager != null) {
            lifeCycleManager.stop();
        }
    }

    @DataProvider(name = "routerEndpoints")
    public Object[][] routerEndpoints()
    {
        return new Object[][] {
                // method, endpoint, expected response with auth
                {"GET", "/", 200},
                {"GET", "/v1/info", 200},
                {"GET", "/v1/cluster", 200},
                // 400 because the test doesn't actually POST a request body
                {"POST", "/v1/statement", 400},
                // TODO: The UI is intentionally left out because the airlift incorrectly configures
                //  some static resources
                // {"GET", "/ui/"}
        };
    }

    @Test(dataProvider = "routerEndpoints")
    public void testEndpointWithoutAuthentication(String method, String endpoint, int unused)
            throws Exception
    {
        Request request = Request.builder()
                .setUri(UriBuilder.fromUri(httpServerInfo.getHttpsUri()).path(endpoint).build())
                .setMethod(method)
                .build();
        httpClient.execute(request, new ResponseHandler<Void, Exception>()
        {
            @Override
            public Void handleException(Request request, Exception exception)
                    throws Exception
            {
                throw exception;
            }

            @Override
            public Void handle(Request request, Response response)
                    throws Exception
            {
                assertEquals(response.getStatusCode(), 401, format("request to %s should not have been successful. Expected 401, got: %d%n%s",
                        request.getUri(), response.getStatusCode(), new String(ByteStreams.toByteArray(response.getInputStream()))));
                return null;
            }
        });
    }

    @Test(dataProvider = "routerEndpoints")
    public void testEndpointWithAuthentication(String method, String endpoint, int expectedCode)
            throws Exception
    {
        Request request = Request.builder()
                .setUri(UriBuilder.fromUri(httpServerInfo.getHttpsUri()).path(endpoint).build())
                .setMethod(method)
                .setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString("testuser:testpass".getBytes(UTF_8)))
                .build();
        httpClient.execute(request, new ResponseHandler<Void, Exception>()
        {
            @Override
            public Void handleException(Request request, Exception exception)
                    throws Exception
            {
                throw exception;
            }

            @Override
            public Void handle(Request request, Response response)
                    throws Exception
            {
                assertEquals(response.getStatusCode(), expectedCode, format("request to %s should have been successful. Expected %d, got: %d%n%s",
                        request.getUri(), expectedCode, response.getStatusCode(), new String(ByteStreams.toByteArray(response.getInputStream()))));
                return null;
            }
        });
    }
}