Repository: calcite Updated Branches: refs/heads/master aa9db8a36 -> 37ed160f4
[CALCITE-1173] HTTP Basic and Digest authentication support Adds support for HTTP basic and digest auth. New builder methods on HttpServer, and new client implementations. Additional docs outlining use. Closes apache/calcite#217 Project: http://git-wip-us.apache.org/repos/asf/calcite/repo Commit: http://git-wip-us.apache.org/repos/asf/calcite/commit/37ed160f Tree: http://git-wip-us.apache.org/repos/asf/calcite/tree/37ed160f Diff: http://git-wip-us.apache.org/repos/asf/calcite/diff/37ed160f Branch: refs/heads/master Commit: 37ed160f423b6c95cecd1b8f9ab4a2a2d220057f Parents: aa9db8a Author: Josh Elser <[email protected]> Authored: Fri Mar 25 21:02:01 2016 -0400 Committer: Josh Elser <[email protected]> Committed: Mon Apr 4 11:34:05 2016 -0400 ---------------------------------------------------------------------- .../avatica/BuiltInConnectionProperty.java | 26 +++ .../calcite/avatica/ConnectionConfig.java | 4 + .../calcite/avatica/ConnectionConfigImpl.java | 8 + .../avatica/remote/AuthenticationType.java | 29 +++ .../remote/AvaticaCommonsHttpClientImpl.java | 172 ++++++++++------- .../remote/AvaticaHttpClientFactoryImpl.java | 39 ++++ .../apache/calcite/avatica/remote/Service.java | 10 +- .../UsernamePasswordAuthenticateable.java | 35 ++++ .../avatica/server/AbstractAvaticaHandler.java | 15 +- .../avatica/server/AuthenticationType.java | 27 --- .../server/AvaticaServerConfiguration.java | 30 +++ .../calcite/avatica/server/HttpServer.java | 188 +++++++++++++++++-- .../server/AbstractAvaticaHandlerTest.java | 2 + .../avatica/server/BasicAuthHttpServerTest.java | 162 ++++++++++++++++ .../server/DigestAuthHttpServerTest.java | 176 +++++++++++++++++ .../calcite/avatica/server/HttpAuthBase.java | 80 ++++++++ .../src/test/resources/auth-users.properties | 20 ++ avatica/site/_docs/client_reference.md | 46 ++++- avatica/site/_docs/security.md | 126 ++++++++++++- .../src/main/config/checkstyle/suppressions.xml | 1 + 20 files changed, 1051 insertions(+), 145 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/BuiltInConnectionProperty.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/BuiltInConnectionProperty.java b/avatica/core/src/main/java/org/apache/calcite/avatica/BuiltInConnectionProperty.java index 3562f25..930b3f4 100644 --- a/avatica/core/src/main/java/org/apache/calcite/avatica/BuiltInConnectionProperty.java +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/BuiltInConnectionProperty.java @@ -19,8 +19,10 @@ package org.apache.calcite.avatica; import org.apache.calcite.avatica.remote.AvaticaHttpClientFactoryImpl; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Properties; +import java.util.Set; import static org.apache.calcite.avatica.ConnectionConfigImpl.PropEnv; import static org.apache.calcite.avatica.ConnectionConfigImpl.parse; @@ -47,6 +49,12 @@ public enum BuiltInConnectionProperty implements ConnectionProperty { /** The type of authentication to be used */ AUTHENTICATION("authentication", Type.STRING, null, false), + /** Avatica-based authentication user name */ + AVATICA_USER("avatica_user", Type.STRING, null, false), + + /** Avatica-based authentication password */ + AVATICA_PASSWORD("avatica_password", Type.STRING, null, false), + /** Factory for constructing http clients. */ HTTP_CLIENT_FACTORY("httpclient_factory", Type.PLUGIN, AvaticaHttpClientFactoryImpl.class.getName(), false), @@ -64,6 +72,7 @@ public enum BuiltInConnectionProperty implements ConnectionProperty { public static final BuiltInConnectionProperty TIMEZONE = TIME_ZONE; private static final Map<String, BuiltInConnectionProperty> NAME_TO_PROPS; + private static final Set<String> LOCAL_PROPS; static { NAME_TO_PROPS = new HashMap<>(); @@ -71,6 +80,11 @@ public enum BuiltInConnectionProperty implements ConnectionProperty { NAME_TO_PROPS.put(p.camelName.toUpperCase(), p); NAME_TO_PROPS.put(p.name(), p); } + + LOCAL_PROPS = new HashSet<>(); + for (BuiltInConnectionProperty p : BuiltInConnectionProperty.values()) { + LOCAL_PROPS.add(p.camelName()); + } } BuiltInConnectionProperty(String camelName, Type type, Object defaultValue, @@ -101,6 +115,18 @@ public enum BuiltInConnectionProperty implements ConnectionProperty { public PropEnv wrap(Properties properties) { return new PropEnv(parse(properties, NAME_TO_PROPS), this); } + + /** + * Checks if the given property only applicable to the remote driver (should not be sent to the + * Avatica server). + * + * @param propertyName Name of the property + * @return True if the property denoted by the given name is only relevant locally, otherwise + * false. + */ + public static boolean isLocalProperty(Object propertyName) { + return LOCAL_PROPS.contains(propertyName); + } } // End BuiltInConnectionProperty.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfig.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfig.java b/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfig.java index 5a6324c..9856452 100644 --- a/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfig.java +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfig.java @@ -35,6 +35,10 @@ public interface ConnectionConfig { String serialization(); /** @see BuiltInConnectionProperty#AUTHENTICATION */ String authentication(); + /** @see BuiltInConnectionProperty#AVATICA_USER */ + String avaticaUser(); + /** @see BuiltInConnectionProperty#AVATICA_PASSWORD */ + String avaticaPassword(); AvaticaHttpClientFactory httpClientFactory(); String httpClientClass(); } http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfigImpl.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfigImpl.java b/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfigImpl.java index cd0325d..b475e8a 100644 --- a/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfigImpl.java +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/ConnectionConfigImpl.java @@ -56,6 +56,14 @@ public class ConnectionConfigImpl implements ConnectionConfig { return BuiltInConnectionProperty.AUTHENTICATION.wrap(properties).getString(); } + public String avaticaUser() { + return BuiltInConnectionProperty.AVATICA_USER.wrap(properties).getString(); + } + + public String avaticaPassword() { + return BuiltInConnectionProperty.AVATICA_PASSWORD.wrap(properties).getString(); + } + public AvaticaHttpClientFactory httpClientFactory() { return BuiltInConnectionProperty.HTTP_CLIENT_FACTORY.wrap(properties) .getPlugin(AvaticaHttpClientFactory.class, null); http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AuthenticationType.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AuthenticationType.java b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AuthenticationType.java new file mode 100644 index 0000000..2662e14 --- /dev/null +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AuthenticationType.java @@ -0,0 +1,29 @@ +/* + * 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.calcite.avatica.remote; + +/** + * An enumeration for support types of authentication for the HttpServer. + */ +public enum AuthenticationType { + NONE, + BASIC, + DIGEST, + SPNEGO; +} + +// End AuthenticationType.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaCommonsHttpClientImpl.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaCommonsHttpClientImpl.java b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaCommonsHttpClientImpl.java index 9cd678e..ffb20a7 100644 --- a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaCommonsHttpClientImpl.java +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaCommonsHttpClientImpl.java @@ -16,19 +16,27 @@ */ package org.apache.calcite.avatica.remote; -import org.apache.http.ConnectionReuseStrategy; -import org.apache.http.HttpClientConnection; import org.apache.http.HttpHost; -import org.apache.http.HttpResponse; +import org.apache.http.auth.AuthSchemeProvider; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.AuthSchemes; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.client.protocol.RequestExpectContinue; +import org.apache.http.config.Lookup; +import org.apache.http.config.RegistryBuilder; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.entity.ContentType; -import org.apache.http.impl.DefaultConnectionReuseStrategy; -import org.apache.http.impl.pool.BasicConnFactory; -import org.apache.http.impl.pool.BasicConnPool; -import org.apache.http.impl.pool.BasicPoolEntry; -import org.apache.http.message.BasicHttpEntityEnclosingRequest; +import org.apache.http.impl.auth.BasicSchemeFactory; +import org.apache.http.impl.auth.DigestSchemeFactory; +import org.apache.http.impl.client.BasicAuthCache; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.protocol.HttpProcessor; import org.apache.http.protocol.HttpProcessorBuilder; import org.apache.http.protocol.HttpRequestExecutor; @@ -41,31 +49,41 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; -import java.util.concurrent.Future; +import java.util.Objects; /** * A common class to invoke HTTP requests against the Avatica server agnostic of the data being * sent and received across the wire. */ -public class AvaticaCommonsHttpClientImpl implements AvaticaHttpClient { +public class AvaticaCommonsHttpClientImpl implements AvaticaHttpClient, + UsernamePasswordAuthenticateable { private static final Logger LOG = LoggerFactory.getLogger(AvaticaCommonsHttpClientImpl.class); - private static final ConnectionReuseStrategy REUSE = DefaultConnectionReuseStrategy.INSTANCE; // Some basic exposed configurations private static final String MAX_POOLED_CONNECTION_PER_ROUTE_KEY = "avatica.pooled.connections.per.route"; - private static final String MAX_POOLED_CONNECTION_PER_ROUTE_DEFAULT = "4"; + private static final String MAX_POOLED_CONNECTION_PER_ROUTE_DEFAULT = "25"; private static final String MAX_POOLED_CONNECTIONS_KEY = "avatica.pooled.connections.max"; - private static final String MAX_POOLED_CONNECTIONS_DEFAULT = "16"; + private static final String MAX_POOLED_CONNECTIONS_DEFAULT = "100"; protected final HttpHost host; + protected final URL url; protected final HttpProcessor httpProcessor; protected final HttpRequestExecutor httpExecutor; - protected final BasicConnPool httpPool; + protected final BasicAuthCache authCache; + protected final CloseableHttpClient client; + final PoolingHttpClientConnectionManager pool; + + protected UsernamePasswordCredentials credentials = null; + protected CredentialsProvider credentialsProvider = null; + protected Lookup<AuthSchemeProvider> authRegistry = null; public AvaticaCommonsHttpClientImpl(URL url) { this.host = new HttpHost(url.getHost(), url.getPort(), url.getProtocol()); + this.url = Objects.requireNonNull(url); this.httpProcessor = HttpProcessorBuilder.create() .add(new RequestContent()) @@ -75,64 +93,84 @@ public class AvaticaCommonsHttpClientImpl implements AvaticaHttpClient { this.httpExecutor = new HttpRequestExecutor(); - this.httpPool = new BasicConnPool(new BasicConnFactory()); - int maxPerRoute = Integer.parseInt( - System.getProperty(MAX_POOLED_CONNECTION_PER_ROUTE_KEY, - MAX_POOLED_CONNECTION_PER_ROUTE_DEFAULT)); - int maxTotal = Integer.parseInt( + pool = new PoolingHttpClientConnectionManager(); + // Increase max total connection to 100 + final String maxCnxns = System.getProperty(MAX_POOLED_CONNECTIONS_KEY, - MAX_POOLED_CONNECTIONS_DEFAULT)); - httpPool.setDefaultMaxPerRoute(maxPerRoute); - httpPool.setMaxTotal(maxTotal); + MAX_POOLED_CONNECTIONS_DEFAULT); + pool.setMaxTotal(Integer.parseInt(maxCnxns)); + // Increase default max connection per route to 25 + final String maxCnxnsPerRoute = System.getProperty(MAX_POOLED_CONNECTION_PER_ROUTE_KEY, + MAX_POOLED_CONNECTION_PER_ROUTE_DEFAULT); + pool.setDefaultMaxPerRoute(Integer.parseInt(maxCnxnsPerRoute)); + + this.authCache = new BasicAuthCache(); + + // A single thread-safe HttpClient, pooling connections via the ConnectionManager + this.client = HttpClients.custom().setConnectionManager(pool).build(); } public byte[] send(byte[] request) { - while (true) { - boolean reusable = false; - // Get a connection from the pool - Future<BasicPoolEntry> future = this.httpPool.lease(host, null); - BasicPoolEntry entry = null; - try { - entry = future.get(); - HttpClientContext context = HttpClientContext.create(); - - context.setTargetHost(host); - - HttpClientConnection conn = entry.getConnection(); - - ByteArrayEntity entity = new ByteArrayEntity(request, ContentType.APPLICATION_OCTET_STREAM); - - BasicHttpEntityEnclosingRequest postRequest = - new BasicHttpEntityEnclosingRequest("POST", "/"); - postRequest.setEntity(entity); - - httpExecutor.preProcess(postRequest, httpProcessor, context); - HttpResponse response = httpExecutor.execute(postRequest, conn, context); - httpExecutor.postProcess(response, httpProcessor, context); - - // Should the connection be kept alive? - reusable = REUSE.keepAlive(response, context); - - final int statusCode = response.getStatusLine().getStatusCode(); - if (HttpURLConnection.HTTP_UNAVAILABLE == statusCode) { - // Could be sitting behind a load-balancer, try again. - continue; - } - - // HTTP-200 and HTTP-500 should both contain Avatica messages. - if (HttpURLConnection.HTTP_OK == statusCode - || HttpURLConnection.HTTP_INTERNAL_ERROR == statusCode) { - return EntityUtils.toByteArray(response.getEntity()); - } - - throw new RuntimeException("Failed to execute HTTP Request, got HTTP/" + statusCode); - } catch (Exception e) { - LOG.debug("Failed to execute HTTP request", e); - throw new RuntimeException(e); - } finally { - // Release the connection back to the pool, marking if it's good to reuse or not. - httpPool.release(entry, reusable); + HttpClientContext context = HttpClientContext.create(); + + context.setTargetHost(host); + + // Set the credentials if they were provided. + if (null != this.credentials) { + context.setCredentialsProvider(credentialsProvider); + context.setAuthSchemeRegistry(authRegistry); + context.setAuthCache(authCache); + } + + ByteArrayEntity entity = new ByteArrayEntity(request, ContentType.APPLICATION_OCTET_STREAM); + + // Create the client with the AuthSchemeRegistry and manager + HttpPost post = new HttpPost(toURI(url)); + post.setEntity(entity); + + try (CloseableHttpResponse response = client.execute(post, context)) { + final int statusCode = response.getStatusLine().getStatusCode(); + if (HttpURLConnection.HTTP_OK == statusCode + || HttpURLConnection.HTTP_INTERNAL_ERROR == statusCode) { + return EntityUtils.toByteArray(response.getEntity()); } + + throw new RuntimeException("Failed to execute HTTP Request, got HTTP/" + statusCode); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + LOG.debug("Failed to execute HTTP request", e); + throw new RuntimeException(e); + } + } + + @Override public void setUsernamePassword(AuthenticationType authType, String username, + String password) { + this.credentials = new UsernamePasswordCredentials( + Objects.requireNonNull(username), Objects.requireNonNull(password)); + + this.credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials(AuthScope.ANY, credentials); + + RegistryBuilder<AuthSchemeProvider> authRegistryBuilder = RegistryBuilder.create(); + switch (authType) { + case BASIC: + authRegistryBuilder.register(AuthSchemes.BASIC, new BasicSchemeFactory()); + break; + case DIGEST: + authRegistryBuilder.register(AuthSchemes.DIGEST, new DigestSchemeFactory()); + break; + default: + throw new IllegalArgumentException("Unsupported authentiation type: " + authType); + } + this.authRegistry = authRegistryBuilder.build(); + } + + private static URI toURI(URL url) throws RuntimeException { + try { + return url.toURI(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); } } } http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaHttpClientFactoryImpl.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaHttpClientFactoryImpl.java b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaHttpClientFactoryImpl.java index e93d9da..1d3ccec 100644 --- a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaHttpClientFactoryImpl.java +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/AvaticaHttpClientFactoryImpl.java @@ -18,6 +18,9 @@ package org.apache.calcite.avatica.remote; import org.apache.calcite.avatica.ConnectionConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.lang.reflect.Constructor; import java.net.URL; import java.util.Objects; @@ -27,6 +30,8 @@ import java.util.Objects; * from a property. */ public class AvaticaHttpClientFactoryImpl implements AvaticaHttpClientFactory { + private static final Logger LOG = LoggerFactory.getLogger(AvaticaHttpClientFactoryImpl.class); + public static final String HTTP_CLIENT_IMPL_DEFAULT = AvaticaCommonsHttpClientImpl.class.getName(); public static final String SPNEGO_HTTP_CLIENT_IMPL_DEFAULT = @@ -58,6 +63,36 @@ public class AvaticaHttpClientFactoryImpl implements AvaticaHttpClientFactory { } } + AvaticaHttpClient client = instantiateClient(className, url); + + if (client instanceof UsernamePasswordAuthenticateable) { + // Shortcircuit quickly if authentication wasn't provided (implies NONE) + final String authString = config.authentication(); + if (null == authString) { + return client; + } + + final AuthenticationType authType = AuthenticationType.valueOf(authString); + final String username = config.avaticaUser(); + final String password = config.avaticaPassword(); + + // Can't authenticate with NONE or w/o username and password + if (isUserPasswordAuth(authType)) { + if (null != username && null != password) { + ((UsernamePasswordAuthenticateable) client) + .setUsernamePassword(authType, username, password); + } else { + LOG.debug("Username or password was null"); + } + } else { + LOG.debug("{} is not capable of username/password authentication.", authType); + } + } + + return client; + } + + private AvaticaHttpClient instantiateClient(String className, URL url) { try { Class<?> clz = Class.forName(className); Constructor<?> constructor = clz.getConstructor(URL.class); @@ -68,6 +103,10 @@ public class AvaticaHttpClientFactoryImpl implements AvaticaHttpClientFactory { + className, e); } } + + private boolean isUserPasswordAuth(AuthenticationType authType) { + return AuthenticationType.BASIC == authType || AuthenticationType.DIGEST == authType; + } } // End AvaticaHttpClientFactoryImpl.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/remote/Service.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/Service.java b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/Service.java index daaf5f0..1bf6353 100644 --- a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/Service.java +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/Service.java @@ -1706,15 +1706,7 @@ public interface Service { Map<String, String> infoAsString = new HashMap<>(); for (Map.Entry<Object, Object> entry : props.entrySet()) { // Determine if this is a property we want to forward to the server - boolean localProperty = false; - for (BuiltInConnectionProperty prop : BuiltInConnectionProperty.values()) { - if (prop.camelName().equals(entry.getKey())) { - localProperty = true; - break; - } - } - - if (!localProperty) { + if (!BuiltInConnectionProperty.isLocalProperty(entry.getKey())) { infoAsString.put(entry.getKey().toString(), entry.getValue().toString()); } } http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/core/src/main/java/org/apache/calcite/avatica/remote/UsernamePasswordAuthenticateable.java ---------------------------------------------------------------------- diff --git a/avatica/core/src/main/java/org/apache/calcite/avatica/remote/UsernamePasswordAuthenticateable.java b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/UsernamePasswordAuthenticateable.java new file mode 100644 index 0000000..9a6afe5 --- /dev/null +++ b/avatica/core/src/main/java/org/apache/calcite/avatica/remote/UsernamePasswordAuthenticateable.java @@ -0,0 +1,35 @@ +/* + * 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.calcite.avatica.remote; + +/** + * Interface that allows configuration of a username and password with some HTTP authentication. + */ +public interface UsernamePasswordAuthenticateable { + + /** + * Sets the username, password and method to be used for authentication. + * + * @param authType Type of authentication + * @param username Username + * @param password Password + */ + void setUsernamePassword(AuthenticationType authType, String username, String password); + +} + +// End UsernamePasswordAuthenticateable.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/main/java/org/apache/calcite/avatica/server/AbstractAvaticaHandler.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/main/java/org/apache/calcite/avatica/server/AbstractAvaticaHandler.java b/avatica/server/src/main/java/org/apache/calcite/avatica/server/AbstractAvaticaHandler.java index 233b36a..3a7ccf3 100644 --- a/avatica/server/src/main/java/org/apache/calcite/avatica/server/AbstractAvaticaHandler.java +++ b/avatica/server/src/main/java/org/apache/calcite/avatica/server/AbstractAvaticaHandler.java @@ -17,6 +17,7 @@ package org.apache.calcite.avatica.server; import org.apache.calcite.avatica.AvaticaSeverity; +import org.apache.calcite.avatica.remote.AuthenticationType; import org.apache.calcite.avatica.remote.Service.ErrorResponse; import org.eclipse.jetty.server.handler.AbstractHandler; @@ -54,12 +55,14 @@ public abstract class AbstractAvaticaHandler extends AbstractHandler public boolean isUserPermitted(AvaticaServerConfiguration serverConfig, HttpServletRequest request, HttpServletResponse response) throws IOException { // Make sure that we drop any unauthenticated users out first. - if (null != serverConfig && AuthenticationType.SPNEGO == serverConfig.getAuthenticationType()) { - String remoteUser = request.getRemoteUser(); - if (null == remoteUser) { - response.setStatus(HttpURLConnection.HTTP_UNAUTHORIZED); - response.getOutputStream().write(UNAUTHORIZED_ERROR.serialize().toByteArray()); - return false; + if (null != serverConfig) { + if (AuthenticationType.SPNEGO == serverConfig.getAuthenticationType()) { + String remoteUser = request.getRemoteUser(); + if (null == remoteUser) { + response.setStatus(HttpURLConnection.HTTP_UNAUTHORIZED); + response.getOutputStream().write(UNAUTHORIZED_ERROR.serialize().toByteArray()); + return false; + } } } http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/main/java/org/apache/calcite/avatica/server/AuthenticationType.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/main/java/org/apache/calcite/avatica/server/AuthenticationType.java b/avatica/server/src/main/java/org/apache/calcite/avatica/server/AuthenticationType.java deleted file mode 100644 index 936affe..0000000 --- a/avatica/server/src/main/java/org/apache/calcite/avatica/server/AuthenticationType.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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.calcite.avatica.server; - -/** - * An enumeration for support types of authentication for the {@link HttpServer}. - */ -public enum AuthenticationType { - NONE, - SPNEGO; -} - -// End AuthenticationType.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/main/java/org/apache/calcite/avatica/server/AvaticaServerConfiguration.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/main/java/org/apache/calcite/avatica/server/AvaticaServerConfiguration.java b/avatica/server/src/main/java/org/apache/calcite/avatica/server/AvaticaServerConfiguration.java index 95eee51..dd843a4 100644 --- a/avatica/server/src/main/java/org/apache/calcite/avatica/server/AvaticaServerConfiguration.java +++ b/avatica/server/src/main/java/org/apache/calcite/avatica/server/AvaticaServerConfiguration.java @@ -16,6 +16,8 @@ */ package org.apache.calcite.avatica.server; +import org.apache.calcite.avatica.remote.AuthenticationType; + import java.util.concurrent.Callable; /** @@ -45,6 +47,34 @@ public interface AvaticaServerConfiguration { String getKerberosPrincipal(); /** + * Returns the array of allowed roles for login. Only applicable when + * {@link #getAuthenticationType()} returns {@link AuthenticationType#BASIC} or + * {@link AuthenticationType#DIGEST}. + * + * @return An array of allowed login roles, or null. + */ + String[] getAllowedRoles(); + + /** + * Returns the name of the realm to use in coordination with the properties files specified + * by {@link #getHashLoginServiceProperties()}. Only applicable when + * {@link #getAuthenticationType()} returns {@link AuthenticationType#BASIC} or + * {@link AuthenticationType#DIGEST}. + * + * @return A realm for the HashLoginService, or null. + */ + String getHashLoginServiceRealm(); + + /** + * Returns the path to a properties file that contains users and realms. Only applicable when + * {@link #getAuthenticationType()} returns {@link AuthenticationType#BASIC} or + * {@link AuthenticationType#DIGEST}. + * + * @return A realm for the HashLoginService, or null. + */ + String getHashLoginServiceProperties(); + + /** * Returns true if the Avatica server should run user requests at that remote user. Otherwise, * all requests are run as the Avatica server user (which is the default). * http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/main/java/org/apache/calcite/avatica/server/HttpServer.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/main/java/org/apache/calcite/avatica/server/HttpServer.java b/avatica/server/src/main/java/org/apache/calcite/avatica/server/HttpServer.java index f99e221..b047137 100644 --- a/avatica/server/src/main/java/org/apache/calcite/avatica/server/HttpServer.java +++ b/avatica/server/src/main/java/org/apache/calcite/avatica/server/HttpServer.java @@ -17,12 +17,18 @@ package org.apache.calcite.avatica.server; import org.apache.calcite.avatica.metrics.MetricsSystemConfiguration; +import org.apache.calcite.avatica.remote.AuthenticationType; import org.apache.calcite.avatica.remote.Driver.Serialization; import org.apache.calcite.avatica.remote.Service; import org.apache.calcite.avatica.remote.Service.RpcMetadataResponse; +import org.eclipse.jetty.security.Authenticator; import org.eclipse.jetty.security.ConstraintMapping; import org.eclipse.jetty.security.ConstraintSecurityHandler; +import org.eclipse.jetty.security.HashLoginService; +import org.eclipse.jetty.security.LoginService; +import org.eclipse.jetty.security.authentication.BasicAuthenticator; +import org.eclipse.jetty.security.authentication.DigestAuthenticator; import org.eclipse.jetty.security.authentication.SpnegoAuthenticator; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Handler; @@ -153,13 +159,19 @@ public class HttpServer { server.manage(threadPool); final ServerConnector connector = configureConnector(new ServerConnector(server), port); - ConstraintSecurityHandler spnegoHandler = null; + ConstraintSecurityHandler securityHandler = null; if (null != this.config) { switch (config.getAuthenticationType()) { case SPNEGO: // Get the Handler for SPNEGO authentication - spnegoHandler = configureSpnego(server, connector, this.config); + securityHandler = configureSpnego(server, connector, this.config); + break; + case BASIC: + securityHandler = configureBasicAuthentication(server, connector, config); + break; + case DIGEST: + securityHandler = configureDigestAuthentication(server, connector, config); break; default: // Pass @@ -173,10 +185,10 @@ public class HttpServer { final HandlerList handlerList = new HandlerList(); Handler avaticaHandler = handler; - // Wrap the provided handler for SPNEGO if we made one - if (null != spnegoHandler) { - spnegoHandler.setHandler(handler); - avaticaHandler = spnegoHandler; + // Wrap the provided handler for security if we made one + if (null != securityHandler) { + securityHandler.setHandler(handler); + avaticaHandler = securityHandler; } handlerList.setHandlers(new Handler[] {avaticaHandler, new DefaultHandler()}); @@ -225,9 +237,49 @@ public class HttpServer { final String realm = Objects.requireNonNull(config.getKerberosRealm()); final String principal = Objects.requireNonNull(config.getKerberosPrincipal()); + // A customization of SpnegoLoginService to explicitly set the server's principal, otherwise + // we would have to require a custom file to set the server's principal. + PropertyBasedSpnegoLoginService spnegoLoginService = + new PropertyBasedSpnegoLoginService(realm, principal); + + return configureCommonAuthentication(server, connector, config, Constraint.__SPNEGO_AUTH, + new String[] {realm}, new SpnegoAuthenticator(), realm, spnegoLoginService); + } + + protected ConstraintSecurityHandler configureBasicAuthentication(Server server, + ServerConnector connector, AvaticaServerConfiguration config) { + final String[] allowedRoles = config.getAllowedRoles(); + final String realm = config.getHashLoginServiceRealm(); + final String loginServiceProperties = config.getHashLoginServiceProperties(); + + HashLoginService loginService = new HashLoginService(realm, loginServiceProperties); + server.addBean(loginService); + + return configureCommonAuthentication(server, connector, config, Constraint.__BASIC_AUTH, + allowedRoles, new BasicAuthenticator(), null, loginService); + } + + protected ConstraintSecurityHandler configureDigestAuthentication(Server server, + ServerConnector connector, AvaticaServerConfiguration config) { + final String[] allowedRoles = config.getAllowedRoles(); + final String realm = config.getHashLoginServiceRealm(); + final String loginServiceProperties = config.getHashLoginServiceProperties(); + + HashLoginService loginService = new HashLoginService(realm, loginServiceProperties); + server.addBean(loginService); + + return configureCommonAuthentication(server, connector, config, Constraint.__DIGEST_AUTH, + allowedRoles, new DigestAuthenticator(), null, loginService); + } + + protected ConstraintSecurityHandler configureCommonAuthentication(Server server, + ServerConnector connector, AvaticaServerConfiguration config, String constraintName, + String[] allowedRoles, Authenticator authenticator, String realm, + LoginService loginService) { + Constraint constraint = new Constraint(); - constraint.setName(Constraint.__SPNEGO_AUTH); - constraint.setRoles(new String[]{realm}); + constraint.setName(constraintName); + constraint.setRoles(allowedRoles); // This is telling Jetty to not allow unauthenticated requests through (very important!) constraint.setAuthenticate(true); @@ -235,14 +287,9 @@ public class HttpServer { cm.setConstraint(constraint); cm.setPathSpec("/*"); - // A customization of SpnegoLoginService to explicitly set the server's principal, otherwise - // we would have to require a custom file to set the server's principal. - PropertyBasedSpnegoLoginService spnegoLoginService = - new PropertyBasedSpnegoLoginService(realm, principal); - ConstraintSecurityHandler sh = new ConstraintSecurityHandler(); - sh.setAuthenticator(new SpnegoAuthenticator()); - sh.setLoginService(spnegoLoginService); + sh.setAuthenticator(authenticator); + sh.setLoginService(loginService); sh.setConstraintMappings(new ConstraintMapping[]{cm}); sh.setRealmName(realm); @@ -312,6 +359,10 @@ public class HttpServer { private DoAsRemoteUserCallback remoteUserCallback; + private String loginServiceRealm; + private String loginServiceProperties; + private String[] loginServiceAllowedRoles; + public Builder() {} public Builder withPort(int port) { @@ -358,7 +409,8 @@ public class HttpServer { /** * Configures the server to use SPNEGO authentication. This method requires that the - * <code>principal</code> contains the Kerberos realm. + * <code>principal</code> contains the Kerberos realm. Invoking this method overrides any + * previous call which configures authentication. * * @param principal A kerberos principal with the realm required. * @return <code>this</code> @@ -377,7 +429,8 @@ public class HttpServer { * Configures the server to use SPNEGO authentication. It is required that callers are logged * in via Kerberos already or have provided the necessary configuration to automatically log * in via JAAS (using the <code>java.security.auth.login.config</code> system property) before - * starting the {@link HttpServer}. + * starting the {@link HttpServer}. Invoking this method overrides any previous call which + * configures authentication. * * @param principal The kerberos principal * @param realm The kerberos realm @@ -414,6 +467,45 @@ public class HttpServer { } /** + * Configures the server to use HTTP Basic authentication. The <code>properties</code> must + * be in a form consumable by Jetty. Invoking this method overrides any previous call which + * configures authentication. This authentication is supplementary to the JDBC-provided user + * authentication interfaces and should only be used when those interfaces are not used. + * + * @param properties Location of a properties file parseable by Jetty which contains users and + * passwords. + * @param allowedRoles An array of allowed roles in the properties file + * @return <code>this</code> + */ + public Builder withBasicAuthentication(String properties, String[] allowedRoles) { + return withAuthentication(AuthenticationType.BASIC, properties, allowedRoles); + } + + /** + * Configures the server to use HTTP Digest authentication. The <code>properties</code> must + * be in a form consumable by Jetty. Invoking this method overrides any previous call which + * configures authentication. This authentication is supplementary to the JDBC-provided user + * authentication interfaces and should only be used when those interfaces are not used. + * + * @param properties Location of a properties file parseable by Jetty which contains users and + * passwords. + * @param allowedRoles An array of allowed roles in the properties file + * @return <code>this</code> + */ + public Builder withDigestAuthentication(String properties, String[] allowedRoles) { + return withAuthentication(AuthenticationType.DIGEST, properties, allowedRoles); + } + + private Builder withAuthentication(AuthenticationType authType, String properties, + String[] allowedRoles) { + this.loginServiceRealm = "Avatica"; + this.authenticationType = authType; + this.loginServiceProperties = Objects.requireNonNull(properties); + this.loginServiceAllowedRoles = Objects.requireNonNull(allowedRoles); + return this; + } + + /** * Builds the HttpServer instance from <code>this</code>. * @return An HttpServer. */ @@ -425,6 +517,12 @@ public class HttpServer { serverConfig = null; subject = null; break; + case BASIC: + case DIGEST: + // Build the configuration for BASIC or DIGEST authentication. + serverConfig = buildUserAuthenticationConfiguration(this); + subject = null; + break; case SPNEGO: if (null != keytab) { LOG.debug("Performing Kerberos login with {} as {}", keytab, kerberosPrincipal); @@ -493,6 +591,62 @@ public class HttpServer { Callable<T> action) throws Exception { return callback.doAsRemoteUser(remoteUserName, remoteAddress, action); } + + @Override public String[] getAllowedRoles() { + return null; + } + + @Override public String getHashLoginServiceRealm() { + return null; + } + + @Override public String getHashLoginServiceProperties() { + return null; + } + }; + } + + private AvaticaServerConfiguration buildUserAuthenticationConfiguration(Builder b) { + final AuthenticationType authType = b.authenticationType; + final String[] allowedRoles = b.loginServiceAllowedRoles; + final String realm = b.loginServiceRealm; + final String properties = b.loginServiceProperties; + + return new AvaticaServerConfiguration() { + @Override public AuthenticationType getAuthenticationType() { + return authType; + } + + @Override public String[] getAllowedRoles() { + return allowedRoles; + } + + @Override public String getHashLoginServiceRealm() { + return realm; + } + + @Override public String getHashLoginServiceProperties() { + return properties; + } + + // Unused + + @Override public String getKerberosRealm() { + return null; + } + + @Override public String getKerberosPrincipal() { + return null; + } + + @Override public boolean supportsImpersonation() { + return false; + } + + @Override public <T> T doAsRemoteUser(String remoteUserName, String remoteAddress, + Callable<T> action) throws Exception { + return null; + } }; } http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/test/java/org/apache/calcite/avatica/server/AbstractAvaticaHandlerTest.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/test/java/org/apache/calcite/avatica/server/AbstractAvaticaHandlerTest.java b/avatica/server/src/test/java/org/apache/calcite/avatica/server/AbstractAvaticaHandlerTest.java index 0260a74..d0290c7 100644 --- a/avatica/server/src/test/java/org/apache/calcite/avatica/server/AbstractAvaticaHandlerTest.java +++ b/avatica/server/src/test/java/org/apache/calcite/avatica/server/AbstractAvaticaHandlerTest.java @@ -16,6 +16,8 @@ */ package org.apache.calcite.avatica.server; +import org.apache.calcite.avatica.remote.AuthenticationType; + import org.hamcrest.BaseMatcher; import org.hamcrest.Description; import org.junit.Before; http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/test/java/org/apache/calcite/avatica/server/BasicAuthHttpServerTest.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/test/java/org/apache/calcite/avatica/server/BasicAuthHttpServerTest.java b/avatica/server/src/test/java/org/apache/calcite/avatica/server/BasicAuthHttpServerTest.java new file mode 100644 index 0000000..70a3c8e --- /dev/null +++ b/avatica/server/src/test/java/org/apache/calcite/avatica/server/BasicAuthHttpServerTest.java @@ -0,0 +1,162 @@ +/* + * 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.calcite.avatica.server; + +import org.apache.calcite.avatica.ConnectionSpec; +import org.apache.calcite.avatica.jdbc.JdbcMeta; +import org.apache.calcite.avatica.remote.Driver; +import org.apache.calcite.avatica.remote.LocalService; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Properties; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +/** + * Test class for HTTP Basic authentication. + */ +public class BasicAuthHttpServerTest extends HttpAuthBase { + + private static final ConnectionSpec CONNECTION_SPEC = ConnectionSpec.HSQLDB; + private static HttpServer server; + private static String url; + + @BeforeClass public static void startServer() throws Exception { + final String userPropertiesFile = BasicAuthHttpServerTest.class + .getResource("/auth-users.properties").getFile(); + assertNotNull("Could not find properties file for basic auth users", userPropertiesFile); + + // Create a LocalService around HSQLDB + final JdbcMeta jdbcMeta = new JdbcMeta(CONNECTION_SPEC.url, + CONNECTION_SPEC.username, CONNECTION_SPEC.password); + LocalService service = new LocalService(jdbcMeta); + + server = new HttpServer.Builder() + .withBasicAuthentication(userPropertiesFile, new String[] { "users" }) + .withHandler(service, Driver.Serialization.PROTOBUF) + .withPort(0) + .build(); + server.start(); + + url = "jdbc:avatica:remote:url=http://localhost:" + server.getPort() + + ";authentication=BASIC;serialization=PROTOBUF"; + + // Create and grant permissions to our users + createHsqldbUsers(); + } + + @AfterClass public static void stopServer() throws Exception { + if (null != server) { + server.stop(); + } + } + + @Test public void testDisallowedAvaticaAllowedDbUser() throws Exception { + // Allowed by avatica and hsqldb + final Properties props = new Properties(); + props.put("avatica_user", "USER2"); + props.put("avatica_password", "foo"); + props.put("user", "USER2"); + props.put("password", "password2"); + + try { + readWriteData(url, "INVALID_AVATICA_USER_VALID_DB_USER", props); + fail("Expected an exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), containsString("HTTP/401")); + } + } + + @Test public void testDisallowedAvaticaNoDbUser() throws Exception { + // Allowed by avatica and hsqldb + final Properties props = new Properties(); + props.put("avatica_user", "USER2"); + props.put("avatica_password", "password2"); + + readWriteData(url, "INVALID_AVATICA_USER_NO_DB_USER", props); + } + + @Test public void testValidUser() throws Exception { + // Allowed by avatica and hsqldb + final Properties props = new Properties(); + props.put("avatica_user", "USER2"); + props.put("avatica_password", "password2"); + props.put("user", "USER2"); + props.put("password", "password2"); + + readWriteData(url, "VALID_USER", props); + } + + @Test public void testInvalidUser() throws Exception { + // Denied by avatica + final Properties props = new Properties(); + props.put("user", "foo"); + props.put("password", "bar"); + + try { + readWriteData(url, "INVALID_USER", props); + fail("Expected an exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), containsString("HTTP/401")); + } + } + + @Test public void testUserWithDisallowedRole() throws Exception { + // Disallowed by avatica + final Properties props = new Properties(); + props.put("avatica_user", "USER4"); + props.put("avatica_password", "password4"); + + try { + readWriteData(url, "DISALLOWED_AVATICA_USER", props); + fail("Expected an exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), containsString("HTTP/403")); + } + } + + @Test public void testDisallowedDbUser() throws Exception { + // Disallowed by hsqldb, allowed by avatica + final Properties props = new Properties(); + props.put("avatica_user", "USER1"); + props.put("avatica_password", "password1"); + props.put("user", "USER1"); + props.put("password", "password1"); + + try { + readWriteData(url, "DISALLOWED_DB_USER", props); + fail("Expected an exception"); + } catch (RuntimeException e) { + assertEquals("Remote driver error: RuntimeException: " + + "java.sql.SQLInvalidAuthorizationSpecException: invalid authorization specification" + + " - not found: USER1" + + " -> SQLInvalidAuthorizationSpecException: invalid authorization specification - " + + "not found: USER1" + + " -> HsqlException: invalid authorization specification - not found: USER1", + e.getMessage()); + } + } +} + +// End BasicAuthHttpServerTest.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/test/java/org/apache/calcite/avatica/server/DigestAuthHttpServerTest.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/test/java/org/apache/calcite/avatica/server/DigestAuthHttpServerTest.java b/avatica/server/src/test/java/org/apache/calcite/avatica/server/DigestAuthHttpServerTest.java new file mode 100644 index 0000000..9d3d273 --- /dev/null +++ b/avatica/server/src/test/java/org/apache/calcite/avatica/server/DigestAuthHttpServerTest.java @@ -0,0 +1,176 @@ +/* + * 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.calcite.avatica.server; + +import org.apache.calcite.avatica.ConnectionSpec; +import org.apache.calcite.avatica.jdbc.JdbcMeta; +import org.apache.calcite.avatica.remote.Driver; +import org.apache.calcite.avatica.remote.LocalService; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Properties; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +/** + * Test class for HTTP Digest authentication. + */ +public class DigestAuthHttpServerTest extends HttpAuthBase { + + private static final ConnectionSpec CONNECTION_SPEC = ConnectionSpec.HSQLDB; + private static HttpServer server; + private static String url; + + @BeforeClass public static void startServer() throws Exception { + final String userPropertiesFile = BasicAuthHttpServerTest.class + .getResource("/auth-users.properties").getFile(); + assertNotNull("Could not find properties file for digest auth users", userPropertiesFile); + + // Create a LocalService around HSQLDB + final JdbcMeta jdbcMeta = new JdbcMeta(CONNECTION_SPEC.url, + CONNECTION_SPEC.username, CONNECTION_SPEC.password); + LocalService service = new LocalService(jdbcMeta); + + server = new HttpServer.Builder() + .withDigestAuthentication(userPropertiesFile, new String[] { "users" }) + .withHandler(service, Driver.Serialization.PROTOBUF) + .withPort(0) + .build(); + server.start(); + + url = "jdbc:avatica:remote:url=http://localhost:" + server.getPort() + + ";authentication=DIGEST;serialization=PROTOBUF"; + + // Create and grant permissions to our users + createHsqldbUsers(); + } + + @AfterClass public static void stopServer() throws Exception { + if (null != server) { + server.stop(); + } + } + + @Test public void testValidUser() throws Exception { + // Valid both with avatica and hsqldb + final Properties props = new Properties(); + props.put("avatica_user", "USER2"); + props.put("avatica_password", "password2"); + props.put("user", "USER2"); + props.put("password", "password2"); + + readWriteData(url, "VALID_USER", props); + } + + @Test public void testInvalidAvaticaValidDb() throws Exception { + // Valid both with avatica and hsqldb + final Properties props = new Properties(); + props.put("avatica_user", "USER2"); + props.put("avatica_password", "foobar"); + props.put("user", "USER2"); + props.put("password", "password2"); + + try { + readWriteData(url, "INVALID_AVATICA_VALID_DB", props); + fail("Expected a failure"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), containsString("HTTP/401")); + } + } + + @Test public void testValidAvaticaNoDb() throws Exception { + // Valid both with avatica and hsqldb + final Properties props = new Properties(); + props.put("avatica_user", "USER2"); + props.put("avatica_password", "password2"); + + readWriteData(url, "VALID_AVATICA_NO_DB", props); + } + + @Test public void testInvalidAvaticaNoDb() throws Exception { + // Valid both with avatica and hsqldb + final Properties props = new Properties(); + props.put("avatica_user", "USER2"); + props.put("avatica_password", "foobar"); + + try { + readWriteData(url, "INVALID_AVATICA_NO_DB", props); + fail("Expected a failure"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), containsString("HTTP/401")); + } + } + + @Test public void testInvalidUser() throws Exception { + // Invalid avatica user + final Properties props = new Properties(); + props.put("avatica_user", "foo"); + props.put("avatica_password", "bar"); + + try { + readWriteData(url, "INVALID_USER", props); + fail("Expected a failure"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), containsString("HTTP/401")); + } + } + + @Test public void testUserWithDisallowedRole() throws Exception { + // User 4 is disallowed in avatica due to its roles + final Properties props = new Properties(); + props.put("avatica_user", "USER4"); + props.put("avatica_password", "password4"); + + try { + readWriteData(url, "DISALLOWED_USER", props); + fail("Expected a failure"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), containsString("HTTP/403")); + } + } + + @Test public void testAllowedAvaticaDisabledHsqldbUser() throws Exception { + // Valid Avatica user, but an invalid database user + final Properties props = new Properties(); + props.put("avatica_user", "USER1"); + props.put("avatica_password", "password1"); + props.put("user", "USER1"); + props.put("password", "password1"); + + try { + readWriteData(url, "DISALLOWED_HSQLDB_USER", props); + fail("Expected a failure"); + } catch (RuntimeException e) { + assertEquals("Remote driver error: RuntimeException: " + + "java.sql.SQLInvalidAuthorizationSpecException: invalid authorization specification" + + " - not found: USER1" + + " -> SQLInvalidAuthorizationSpecException: invalid authorization specification - " + + "not found: USER1" + + " -> HsqlException: invalid authorization specification - not found: USER1", + e.getMessage()); + } + } +} + +// End DigestAuthHttpServerTest.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/test/java/org/apache/calcite/avatica/server/HttpAuthBase.java ---------------------------------------------------------------------- diff --git a/avatica/server/src/test/java/org/apache/calcite/avatica/server/HttpAuthBase.java b/avatica/server/src/test/java/org/apache/calcite/avatica/server/HttpAuthBase.java new file mode 100644 index 0000000..cfaf302 --- /dev/null +++ b/avatica/server/src/test/java/org/apache/calcite/avatica/server/HttpAuthBase.java @@ -0,0 +1,80 @@ +/* + * 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.calcite.avatica.server; + +import org.apache.calcite.avatica.ConnectionSpec; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Common test logic for HTTP basic and digest auth + */ +public class HttpAuthBase { + + static boolean userExists(Statement stmt, String user) throws SQLException { + ResultSet results = stmt.executeQuery( + "SELECT * FROM INFORMATION_SCHEMA.SYSTEM_USERS WHERE USER_NAME = '" + user + "'"); + return results.next(); + } + + static void createHsqldbUsers() throws SQLException { + try (Connection conn = DriverManager.getConnection(ConnectionSpec.HSQLDB.url, + ConnectionSpec.HSQLDB.username, ConnectionSpec.HSQLDB.password); + Statement stmt = conn.createStatement()) { + for (int i = 2; i <= 5; i++) { + // Users 2-5 exist (not user1) + final String username = "USER" + i; + final String password = "password" + i; + if (userExists(stmt, username)) { + stmt.execute("DROP USER " + username); + } + stmt.execute("CREATE USER " + username + " PASSWORD '" + password + "'"); + // Grant permission to the user we create (defined in the scottdb hsqldb impl) + stmt.execute("GRANT DBA TO " + username); + } + } + } + + void readWriteData(String url, String tableName, Properties props) throws Exception { + try (Connection conn = DriverManager.getConnection(url, props); + Statement stmt = conn.createStatement()) { + assertFalse(stmt.execute("DROP TABLE IF EXISTS " + tableName)); + assertFalse(stmt.execute("CREATE TABLE " + tableName + " (pk integer, msg varchar(10))")); + + assertEquals(1, stmt.executeUpdate("INSERT INTO " + tableName + " VALUES(1, 'abcd')")); + assertEquals(1, stmt.executeUpdate("INSERT INTO " + tableName + " VALUES(2, 'bcde')")); + assertEquals(1, stmt.executeUpdate("INSERT INTO " + tableName + " VALUES(3, 'cdef')")); + + ResultSet results = stmt.executeQuery("SELECT count(1) FROM " + tableName); + assertNotNull(results); + assertTrue(results.next()); + assertEquals(3, results.getInt(1)); + } + } +} + +// End HttpAuthBase.java http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/server/src/test/resources/auth-users.properties ---------------------------------------------------------------------- diff --git a/avatica/server/src/test/resources/auth-users.properties b/avatica/server/src/test/resources/auth-users.properties new file mode 100644 index 0000000..be19e02 --- /dev/null +++ b/avatica/server/src/test/resources/auth-users.properties @@ -0,0 +1,20 @@ +# 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. + +USER1: password1,role1,users +USER2: password2,role2,users +USER3: password3,role3,users +USER4: password4,role4,admins +USER5: password5,role5,admins http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/site/_docs/client_reference.md ---------------------------------------------------------------------- diff --git a/avatica/site/_docs/client_reference.md b/avatica/site/_docs/client_reference.md index f17b22c..65cf6f4 100644 --- a/avatica/site/_docs/client_reference.md +++ b/avatica/site/_docs/client_reference.md @@ -35,7 +35,13 @@ As a reminder, the JDBC connection URL for Avatica is: The following are a list of supported options: -**url** +{% comment %} +It's a shame that we have to embed HTML to get the anchors but the normal +header tags from kramdown screw up the definition list. We lose the pretty +on-hover images for the permalink, but oh well. +{% endcomment %} + +<strong><a name="url" href="#url">url</a></strong> : _Description_: This property is a URL which refers to the location of the Avatica Server which the driver will communicate with. @@ -46,7 +52,7 @@ The following are a list of supported options: : _Required_: Yes. -**serialization** +<strong><a name="serialization" href="#serialization">serialization</a></strong> : _Description_: Avatica supports multiple types of serialization mechanisms to format data between the client and server. This property is used to ensure @@ -58,19 +64,19 @@ The following are a list of supported options: : _Required_: No. -**authentication** +<strong><a name="authentication" href="#authentication">authentication</a></strong> : _Description_: Avatica clients can specify the means in which it authenticates - with the Avatica server. Presently, the only form of authentication is SPNEGO - which enables Kerberos authentication. Clients who want to use a specific form - of authentication should specify the appropriate value in this property. + with the Avatica server. Clients who want to use a specific form + of authentication should specify the appropriate value in this property. Valid + values for this property are presently: `NONE`, `BASIC`, `DIGEST`, and `SPNEGO`. -: _Default_: `null` (implying "no authentication"). +: _Default_: `null` (implying "no authentication", equivalent to `NONE`). : _Required_: No. -**timeZone** +<strong><a name="timeZone" href="#timeZone">timeZone</a></strong> : _Description_: The timezone that will be used for dates and times. Valid values for this property are defined by [RFC 822](https://www.ietf.org/rfc/rfc0822.txt), for @@ -83,7 +89,7 @@ The following are a list of supported options: : _Required_: No. -**httpclient_factory** +<strong><a name="httpclient-factory" href="#httpclient-factory">httpclient_factory</a></strong> : _Description_: The Avatica client is a "fancy" HTTP client. As such, there are many libraries and APIs available for making HTTP calls. To determine which implementation @@ -95,7 +101,7 @@ The following are a list of supported options: : _Required_: No. -**httpclient_impl** +<strong><a name="httpclient-impl" href="#httpclient-impl">httpclient_impl</a></strong> : _Description_: When using the default `AvaticaHttpClientFactoryImpl` HTTP client factory implementation, this factory should choose the correct client implementation for the @@ -106,3 +112,23 @@ The following are a list of supported options: : _Default_: `null`. : _Required_: No. + +<strong><a name="avatica-user" href="#avatica-user">avatica_user</a></strong> + +: _Description_: This is the username used by an Avatica client to identify itself + to the Avatica server. It is unique to the traditional "user" JDBC property. It + is only necessary if Avatica is configured for HTTP Basic or Digest authentication. + +: _Default_: `null`. + +: _Required_: No. + +<strong><a name="avatica-password" href="#avatica-password">avatica_password</a></strong> + +: _Description_: This is the password used by an Avatica client to identify itself + to the Avatica server. It is unique to the traditional "password" JDBC property. It + is only necessary if Avatica is configured for HTTP Basic or Digest authentication. + +: _Default_: `null`. + +: _Required_: No. http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/site/_docs/security.md ---------------------------------------------------------------------- diff --git a/avatica/site/_docs/security.md b/avatica/site/_docs/security.md index c98f0a5..306ace0 100644 --- a/avatica/site/_docs/security.md +++ b/avatica/site/_docs/security.md @@ -3,6 +3,11 @@ layout: docs title: Security sidebar_title: Security permalink: /docs/security.html +auth_types: + - { name: "HTTP Basic", anchor: "http-basic-authentication" } + - { name: "HTTP Digest", anchor: "http-digest-authentication" } + - { name: "Kerberos with SPNEGO", anchor: "kerberos-with-spnego-authentication" } + - { name: "Client implementation", anchor: "client-implementation" } --- <!-- {% comment %} @@ -30,16 +35,107 @@ for limit what actions clients are allowed to perform. Similarly, Avatica must limit what users are allowed to connect and interact with the server. Avatica must primarily deal with authentication while authorization is deferred to the underlying database. By default, Avatica provides no authentication. -Avatica does have the ability to perform client authentication using Kerberos. +Avatica does have the ability to perform client authentication using Kerberos, +HTTP Basic, and HTTP Digest. -## Kerberos-based authentication +The authentication and authorization provided by Avatica are designed for use +*instead* of the authentication and authorization provided by the underlying database. +The typical `user` and `password` JDBC properties are **always** passed through to +the Avatica server which will cause the server to enforce those credentials. As such, +Avatica's authentication types mentioned here only have relevance when the underlying database's authentication +and authorization features are not used. (The Kerberos/SPNEGO integration is one difference as the impersonation feature +is specifically designed to allow the Kerberos identity to be passed to the database - +new advanced implementations could also follow this same approach if desired). + +## Table of Contents +<ul> + {% for item in page.auth_types %}<li><a href="#{{ item.anchor }}">{{ item.name }}</a></li>{% endfor %} +</ul> + + +## HTTP Basic Authentication + +Avatica supports authentication over [HTTP Basic](https://en.wikipedia.org/wiki/Basic_access_authentication). +This is simple username-password based authentication which is ultimately insecure when +operating over an untrusted network. Basic authentication is only secure when the transport +is encrypted (e.g. TLS) as the credentials are passed in the clear. This authentication is +supplementary to the provided JDBC authentication. If credentials are passed to the database +already, this authentication is unnecessary. + +### Enabling Basic Authentication + +{% highlight java %} +String propertiesFile = "/path/to/jetty-users.properties"; +// All roles allowed +String[] allowedRoles = new String[] {"*"}; +// Only specific roles are allowed +allowedRoles = new String[] { "users", "admins" }; +HttpServer server = new HttpServer.Builder() + .withPort(8765) + .withHandler(new LocalService(), Driver.Serialization.PROTOBUF) + .withBasicAuthentication(propertiesFile, allowedRoles) + .build(); +{% endhighlight %} + +The properties file must be in a form consumable by Jetty. Each line in this +file is of the form: `username: password[,rolename ...]` + +For example: + +{% highlight properties %} +bob: b0b5pA55w0rd,users +steve: 5teve5pA55w0rd,users +alice: Al1cepA55w0rd,admins +{% endhighlight %} + +Passwords can also be obfuscated as MD5 hashes or oneway cryptography ("CRYPT"). +For more information, see the [official Jetty documentation](http://www.eclipse.org/jetty/documentation/current/configuring-security-secure-passwords.html). + +## HTTP Digest Authentication + +Avatica also supports [HTTP Digest](https://en.wikipedia.org/wiki/Digest_access_authentication). +This is desirable for Avatica as it does not require the use of TLS to secure communication +between the Avatica client and server. It is configured very similarly to HTTP Basic +authentication. This authentication is supplementary to the provided JDBC authentication. +If credentials are passed to the database already, this authentication is unnecessary. + +### Enabling Digest Authentication + +{% highlight java %} +String propertiesFile = "/path/to/jetty-users.properties"; +// All roles allowed +String[] allowedRoles = new String[] {"*"}; +// Only specific roles are allowed +allowedRoles = new String[] { "users", "admins" }; +HttpServer server = new HttpServer.Builder() + .withPort(8765) + .withHandler(new LocalService(), Driver.Serialization.PROTOBUF) + .withDigestAuthentication(propertiesFile, allowedRoles) + .build(); +{% endhighlight %} + +The properties file must be in a form consumable by Jetty. Each line in this +file is of the form: `username: password[,rolename ...]` + +For example: + +{% highlight properties %} +bob: b0b5pA55w0rd,users +steve: 5teve5pA55w0rd,users +alice: Al1cepA55w0rd,admins +{% endhighlight %} + +Passwords can also be obfuscated as MD5 hashes or oneway cryptography ("CRYPT"). +For more information, see the [official Jetty documentation](http://www.eclipse.org/jetty/documentation/current/configuring-security-secure-passwords.html). + +## Kerberos with SPNEGO Authentication Because Avatica operates over an HTTP interface, the simple and protected GSSAPI negotiation mechanism ([SPNEGO](https://en.wikipedia.org/wiki/SPNEGO)) is a logical choice. This mechanism makes use of the "HTTP Negotiate" authentication extension to communicate with the Kerberos Key Distribution Center (KDC) to authenticate a client. -## Enabling SPNEGO/Kerberos Authentication in servers +### Enabling SPNEGO/Kerberos Authentication in servers The Avatica server can operate either by performing the login using a JAAS configuration file or login programmatically. By default, authenticated clients @@ -50,7 +146,7 @@ As a note, it is required that the Kerberos principal in use by the Avatica serv **must** have an primary of `HTTP` (where Kerberos principals are of the form `primary[/instance]@REALM`). This is specified by [RFC-4559](https://tools.ietf.org/html/rfc4559). -### Programmatic Login +#### Programmatic Login This approach requires no external file configurations and only requires a keytab file for the principal. @@ -65,7 +161,7 @@ HttpServer server = new HttpServer.Builder() .build(); {% endhighlight %} -### JAAS Configuration File Login +#### JAAS Configuration File Login A JAAS configuration file can be set via the system property `java.security.auth.login.config`. The user must set this property when launching their Java application invoking the Avatica server. @@ -94,7 +190,7 @@ com.sun.security.jgss.accept { Ensure the `keyTab` and `principal` attributes are set correctly for your system. -## Impersonation +### Impersonation Impersonation is a feature of the Avatica server which allows the Avatica clients to execute the server-side calls (e.g. the underlying JDBC calls). Because the details @@ -137,9 +233,21 @@ public class PhoenixDoAsCallback implements DoAsRemoteUserCallback { ## Client implementation Many HTTP client libraries, such as [Apache Commons HttpComponents](https://hc.apache.org/), already have -support for performing SPNEGO authentication. When in doubt, refer to one of +support for performing Basic, Digest, and SPNEGO authentication. When in doubt, refer to one of these implementations as it is likely correct. -For information on building this by hand, consult [RFC-4559](https://tools.ietf.org/html/rfc4559) -which describes how the authentication handshake, through use of the "WWW-authenticate" +### SPNEGO + +For information on building SPNEGO support by hand, consult [RFC-4559](https://tools.ietf.org/html/rfc4559) +which describes how the authentication handshake, through use of the "WWW-authenticate=Negotiate" HTTP header, is used to authenticate a client. + +### Password-based + +For both HTTP Basic and Digest authentication, the [avatica_user]({{site.baseurl}}/docs/client_reference.html#avatica-user) +and [avatica_password]({{site.baseurl}}/docs/client_reference.html#avatica-password) +properties are used to identify the client with the server. If the underlying database +(the JDBC driver inside the Avatica server) require their own user and password combination, +these are set via the traditional "user" and "password" properties in the Avatica +JDBC driver. This also implies that adding HTTP-level authentication in Avatica is likely +superfluous. http://git-wip-us.apache.org/repos/asf/calcite/blob/37ed160f/avatica/src/main/config/checkstyle/suppressions.xml ---------------------------------------------------------------------- diff --git a/avatica/src/main/config/checkstyle/suppressions.xml b/avatica/src/main/config/checkstyle/suppressions.xml index d9ae88a..9c31837 100644 --- a/avatica/src/main/config/checkstyle/suppressions.xml +++ b/avatica/src/main/config/checkstyle/suppressions.xml @@ -29,6 +29,7 @@ limitations under the License. <suppress checks=".*" files="release.properties"/> <suppress checks=".*" files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]apache[/\\]calcite[/\\]avatica[/\\]proto"/> <suppress checks=".*" files="log4j.properties"/> + <suppress checks=".*" files="auth-users.properties"/> <!-- This file triggers https://github.com/checkstyle/checkstyle/issues/92, through no fault of its own. -->
