diff --git a/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpConnection.java b/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpConnection.java index 814939428..b8e9035e5 100644 --- a/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpConnection.java +++ b/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpConnection.java @@ -9,6 +9,7 @@ import java.net.URLEncoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.*; import java.util.Map.Entry; @@ -31,6 +32,7 @@ import com.clickhouse.data.ClickHouseUtils; import com.clickhouse.logging.Logger; import com.clickhouse.logging.LoggerFactory; +import org.apache.hc.core5.http.HttpHeaders; public abstract class ClickHouseHttpConnection implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(ClickHouseHttpConnection.class); @@ -231,11 +233,19 @@ protected static Map createDefaultHeaders(ClickHouseConfig confi // TODO check if auth-scheme is available and supported map.put("authorization", credentials.getAccessToken()); } else if (!hasAuthorizationHeader) { - map.put("x-clickhouse-user", credentials.getUserName()); if (config.isSsl() && !ClickHouseChecker.isNullOrEmpty(config.getSslCert())) { - map.put("x-clickhouse-ssl-certificate-auth", "on"); - } else if (!ClickHouseChecker.isNullOrEmpty(credentials.getPassword())) { - map.put("x-clickhouse-key", credentials.getPassword()); + map.put(ClickHouseHttpProto.HEADER_DB_USER, credentials.getUserName()); + map.put(ClickHouseHttpProto.HEADER_SSL_CERT_AUTH, "on"); + } else { + boolean useBasicAuthentication = config.getBoolOption(ClickHouseHttpOption.USE_BASIC_AUTHENTICATION); + if (useBasicAuthentication) { + String password = credentials.getPassword() == null ? "" : credentials.getPassword(); + map.put(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder() + .encodeToString((credentials.getUserName() + ":" + password).getBytes(StandardCharsets.UTF_8))); + } else { + map.put(ClickHouseHttpProto.HEADER_DB_USER, credentials.getUserName()); + map.put(ClickHouseHttpProto.HEADER_DB_PASSWORD, credentials.getPassword()); + } } } diff --git a/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpProto.java b/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpProto.java index 833cb016d..a2f3f8a8c 100644 --- a/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpProto.java +++ b/clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpProto.java @@ -44,6 +44,10 @@ public class ClickHouseHttpProto { */ public static final String HEADER_DB_USER = "X-ClickHouse-User"; + /** + * Password of user to be used to authenticate. Note: header value should be unencoded, so using + * special characters might cause issues. It is recommended to use the Basic Authentication instead. + */ public static final String HEADER_DB_PASSWORD = "X-ClickHouse-Key"; public static final String HEADER_SSL_CERT_AUTH = "x-clickhouse-ssl-certificate-auth"; diff --git a/clickhouse-http-client/src/main/java/com/clickhouse/client/http/config/ClickHouseHttpOption.java b/clickhouse-http-client/src/main/java/com/clickhouse/client/http/config/ClickHouseHttpOption.java index f406b4c36..08040fddb 100644 --- a/clickhouse-http-client/src/main/java/com/clickhouse/client/http/config/ClickHouseHttpOption.java +++ b/clickhouse-http-client/src/main/java/com/clickhouse/client/http/config/ClickHouseHttpOption.java @@ -107,7 +107,13 @@ public enum ClickHouseHttpOption implements ClickHouseOption { */ KEEP_ALIVE_TIMEOUT("alive_timeout", -1L, "Default keep-alive timeout in milliseconds."), - ; + + /** + * Whether to use HTTP basic authentication. Default value is true. + * Password that contain UTF8 characters may not be passed through http headers and BASIC authentication + * is the only option here. + */ + USE_BASIC_AUTHENTICATION("http_use_basic_auth", true, "Whether to use basic authentication."); private final String key; private final Serializable defaultValue; diff --git a/clickhouse-jdbc/src/test/java/com/clickhouse/jdbc/AccessManagementTest.java b/clickhouse-jdbc/src/test/java/com/clickhouse/jdbc/AccessManagementTest.java index 909acf950..b3630b78b 100644 --- a/clickhouse-jdbc/src/test/java/com/clickhouse/jdbc/AccessManagementTest.java +++ b/clickhouse-jdbc/src/test/java/com/clickhouse/jdbc/AccessManagementTest.java @@ -176,4 +176,89 @@ public void testSetRolesAccessingTableRows() throws SQLException { Assert.fail("Failed to check roles", e); } } + + @Test(groups = "integration", dataProvider = "passwordAuthMethods") + public void testPasswordAuthentication(String identifyWith, String identifyBy) throws SQLException { + if (isCloud()) return; // Doesn’t allow to create users with specific passwords + String url = String.format("jdbc:ch:%s", getEndpointString()); + Properties properties = new Properties(); + properties.setProperty(ClickHouseHttpOption.REMEMBER_LAST_SET_ROLES.getKey(), "true"); + ClickHouseDataSource dataSource = new ClickHouseDataSource(url, properties); + + try (Connection connection = dataSource.getConnection("access_dba", "123")) { + Statement st = connection.createStatement(); + st.execute("DROP USER IF EXISTS some_user"); + st.execute("CREATE USER some_user IDENTIFIED WITH " + identifyWith + " BY '" + identifyBy + "'"); + } catch (Exception e) { + Assert.fail("Failed on setup", e); + } + + try (Connection connection = dataSource.getConnection("some_user", identifyBy)) { + Statement st = connection.createStatement(); + ResultSet rs = st.executeQuery("SELECT user() AS user_name"); + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getString(1), "some_user"); + } catch (Exception e) { + Assert.fail("Failed to authenticate", e); + } + } + + @DataProvider(name = "passwordAuthMethods") + private static Object[][] passwordAuthMethods() { + return new Object[][] { + { "plaintext_password", "password" }, + { "plaintext_password", "" }, + { "plaintext_password", "S3Cr=?t"}, + { "plaintext_password", "123§" }, + { "sha256_password", "password" }, + { "sha256_password", "123§" }, + { "sha256_password", "S3Cr=?t"}, + { "sha256_password", "S3Cr?=t"}, + }; + } + + @Test(groups = "integration", dataProvider = "headerAuthDataProvider") + public void testSwitchingBasicAuthToClickHouseHeaders(String identifyWith, String identifyBy, boolean shouldFail) throws SQLException { + if (isCloud()) return; // Doesn't allow to create users with specific passwords + String url = String.format("jdbc:ch:%s", getEndpointString()); + Properties properties = new Properties(); + properties.put(ClickHouseHttpOption.USE_BASIC_AUTHENTICATION.getKey(), false); + ClickHouseDataSource dataSource = new ClickHouseDataSource(url, properties); + + try (Connection connection = dataSource.getConnection("access_dba", "123")) { + Statement st = connection.createStatement(); + st.execute("DROP USER IF EXISTS some_user"); + st.execute("CREATE USER some_user IDENTIFIED WITH " + identifyWith + " BY '" + identifyBy + "'"); + } catch (Exception e) { + Assert.fail("Failed on setup", e); + } + + try (Connection connection = dataSource.getConnection("some_user", identifyBy)) { + Statement st = connection.createStatement(); + ResultSet rs = st.executeQuery("SELECT user() AS user_name"); + Assert.assertTrue(rs.next()); + Assert.assertEquals(rs.getString(1), "some_user"); + if (shouldFail) { + Assert.fail("Expected authentication to fail"); + } + } catch (Exception e) { + if (!shouldFail) { + Assert.fail("Failed to authenticate", e); + } + } + } + + @DataProvider(name = "headerAuthDataProvider") + private static Object[][] headerAuthDataProvider() { + return new Object[][] { + { "plaintext_password", "password", false }, + { "plaintext_password", "", false }, + { "plaintext_password", "S3Cr=?t", true}, + { "plaintext_password", "123§", true }, + { "sha256_password", "password", false}, + { "sha256_password", "123§", true }, + { "sha256_password", "S3Cr=?t", true}, + { "sha256_password", "S3Cr?=t", false}, + }; + } } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index 4d9001ecd..ea887916b 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -876,6 +876,16 @@ public Builder columnToMethodMatchingStrategy(ColumnToMethodMatchingStrategy str return this; } + /** + * Whether to use HTTP basic authentication. Default value is true. + * Password that contain UTF8 characters may not be passed through http headers and BASIC authentication + * is the only option here. + */ + public Builder useHTTPBasicAuth(boolean useBasicAuth) { + this.configuration.put(ClientSettings.HTTP_USE_BASIC_AUTH, String.valueOf(useBasicAuth)); + return this; + } + public Client build() { setDefaults(); @@ -1009,6 +1019,10 @@ private void setDefaults() { if (columnToMethodMatchingStrategy == null) { columnToMethodMatchingStrategy = DefaultColumnToMethodMatchingStrategy.INSTANCE; } + + if (!configuration.containsKey(ClientSettings.HTTP_USE_BASIC_AUTH)) { + useHTTPBasicAuth(true); + } } } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/ClientSettings.java b/client-v2/src/main/java/com/clickhouse/client/api/ClientSettings.java index 6728b8278..622ada822 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/ClientSettings.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/ClientSettings.java @@ -37,4 +37,6 @@ public static List valuesFromCommaSeparated(String value) { public static final String SESSION_DB_ROLES = "session_db_roles"; public static final String SETTING_LOG_COMMENT = SERVER_SETTING_PREFIX + "log_comment"; + + public static final String HTTP_USE_BASIC_AUTH = "http_use_basic_auth"; } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index 33c1bdf8d..1569af01d 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -63,6 +63,7 @@ import java.net.NoRouteToHostException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; @@ -338,13 +339,14 @@ public ClassicHttpResponse executeRequest(ClickHouseNode server, Map chConfig, Map chConfig, Map chConfig, Map requestConfig) { if (requestConfig == null) { @@ -487,15 +503,26 @@ private void addQueryParams(URIBuilder req, Map chConfig, Map + client.queryAll("SELECT user()").get(0).getString(1)); + + } catch (Exception e) { + Assert.fail("Unexpected exception", e); + } + } + } + + @DataProvider(name = "testPasswordAuthenticationProvider") + public static Object[][] testPasswordAuthenticationProvider() { + return new Object[][] { + { "plaintext_password", "password", false}, + { "plaintext_password", "", false }, + { "plaintext_password", "S3Cr=?t", true}, + { "plaintext_password", "123§", true }, + { "sha256_password", "password", false }, + { "sha256_password", "123§", true }, + { "sha256_password", "S3Cr=?t", true}, + { "sha256_password", "S3Cr?=t", false}, + }; + } + + @Test(groups = { "integration" }) + public void testAuthHeaderIsKeptFromUser() throws Exception { + if (isCloud()) { + return; // Current test is working only with local server because of self-signed certificates. + } + ClickHouseNode server = getServer(ClickHouseProtocol.HTTP); + + String identifyWith = "sha256_password"; + String identifyBy = "123§"; + try (Client client = new Client.Builder().addEndpoint(Protocol.HTTP, "localhost",server.getPort(), false) + .setUsername("default") + .setPassword("") + .build()) { + + try (CommandResponse resp = client.execute("DROP USER IF EXISTS some_user").get()) { + } + try (CommandResponse resp = client.execute("CREATE USER some_user IDENTIFIED WITH " + identifyWith + " BY '" + identifyBy + "'").get()) { + } + } catch (Exception e) { + Assert.fail("Failed on setup", e); + } + + + try (Client client = new Client.Builder().addEndpoint(Protocol.HTTP, "localhost",server.getPort(), false) + .setUsername("some_user") + .setPassword(identifyBy) + .useHTTPBasicAuth(false) // disable basic auth to produce CH headers + .httpHeader(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(("some_user:" +identifyBy).getBytes())) + .build()) { + + Assert.assertEquals(client.queryAll("SELECT user()").get(0).getString(1), "some_user"); + } catch (Exception e) { + Assert.fail("Failed to authenticate", e); + } + } + @Test(groups = { "integration" }) public void testSSLAuthentication_invalidConfig() throws Exception { if (isCloud()) {