Skip to content

Commit

Permalink
Merge pull request #1892 from ClickHouse/fix_password_authentication
Browse files Browse the repository at this point in the history
[client-v1,client-v2, auth] Implemented authentication via http basic auth
  • Loading branch information
chernser authored Oct 31, 2024
2 parents 714dcf8 + 842f519 commit 85f7bae
Show file tree
Hide file tree
Showing 8 changed files with 265 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down Expand Up @@ -231,11 +233,19 @@ protected static Map<String, String> 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());
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
}
}
14 changes: 14 additions & 0 deletions client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -1009,6 +1019,10 @@ private void setDefaults() {
if (columnToMethodMatchingStrategy == null) {
columnToMethodMatchingStrategy = DefaultColumnToMethodMatchingStrategy.INSTANCE;
}

if (!configuration.containsKey(ClientSettings.HTTP_USE_BASIC_AUTH)) {
useHTTPBasicAuth(true);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ public static List<String> 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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -338,13 +339,14 @@ public ClassicHttpResponse executeRequest(ClickHouseNode server, Map<String, Obj
.build();
req.setConfig(httpReqConfig);
// setting entity. wrapping if compression is enabled
req.setEntity(wrapEntity(new EntityTemplate(-1, CONTENT_TYPE, null, writeCallback), false));
req.setEntity(wrapEntity(new EntityTemplate(-1, CONTENT_TYPE, null, writeCallback), HttpStatus.SC_OK, false));

HttpClientContext context = HttpClientContext.create();

try {
ClassicHttpResponse httpResponse = httpClient.executeOpen(null, req, context);
httpResponse.setEntity(wrapEntity(httpResponse.getEntity(), true));
httpResponse.setEntity(wrapEntity(httpResponse.getEntity(), httpResponse.getCode(), true));

if (httpResponse.getCode() == HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED) {
throw new ClientMisconfigurationException("Proxy authentication required. Please check your proxy settings.");
} else if (httpResponse.getCode() == HttpStatus.SC_BAD_GATEWAY) {
Expand Down Expand Up @@ -395,11 +397,17 @@ private void addHeaders(HttpPost req, Map<String, String> chConfig, Map<String,
req.addHeader(ClickHouseHttpProto.HEADER_QUERY_ID, requestConfig.get(ClickHouseClientOption.QUERY_ID.getKey()).toString());
}
}
req.addHeader(ClickHouseHttpProto.HEADER_DB_USER, chConfig.get(ClickHouseDefaults.USER.getKey()));

if (MapUtils.getFlag(chConfig, "ssl_authentication", false)) {
req.addHeader(ClickHouseHttpProto.HEADER_DB_USER, chConfig.get(ClickHouseDefaults.USER.getKey()));
req.addHeader(ClickHouseHttpProto.HEADER_SSL_CERT_AUTH, "on");
} else if (chConfig.getOrDefault(ClientSettings.HTTP_USE_BASIC_AUTH, "true").equalsIgnoreCase("true")) {
req.addHeader(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(
(chConfig.get(ClickHouseDefaults.USER.getKey()) + ":" + chConfig.get(ClickHouseDefaults.PASSWORD.getKey())).getBytes(StandardCharsets.UTF_8)));
} else {
req.addHeader(ClickHouseHttpProto.HEADER_DB_USER, chConfig.get(ClickHouseDefaults.USER.getKey()));
req.addHeader(ClickHouseHttpProto.HEADER_DB_PASSWORD, chConfig.get(ClickHouseDefaults.PASSWORD.getKey()));

}
if (proxyAuthHeaderValue != null) {
req.addHeader(HttpHeaders.PROXY_AUTHORIZATION, proxyAuthHeaderValue);
Expand Down Expand Up @@ -428,6 +436,14 @@ private void addHeaders(HttpPost req, Map<String, String> chConfig, Map<String,
req.addHeader(entry.getKey().substring(ClientSettings.HTTP_HEADER_PREFIX.length()), entry.getValue().toString());
}
}

// Special cases
if (req.containsHeader(HttpHeaders.AUTHORIZATION) && (req.containsHeader(ClickHouseHttpProto.HEADER_DB_USER) ||
req.containsHeader(ClickHouseHttpProto.HEADER_DB_PASSWORD))) {
// user has set auth header for purpose, lets remove ours
req.removeHeaders(ClickHouseHttpProto.HEADER_DB_USER);
req.removeHeaders(ClickHouseHttpProto.HEADER_DB_PASSWORD);
}
}
private void addQueryParams(URIBuilder req, Map<String, String> chConfig, Map<String, Object> requestConfig) {
if (requestConfig == null) {
Expand Down Expand Up @@ -487,15 +503,26 @@ private void addQueryParams(URIBuilder req, Map<String, String> chConfig, Map<St
}
}

private HttpEntity wrapEntity(HttpEntity httpEntity, boolean isResponse) {
boolean serverCompression = chConfiguration.getOrDefault(ClickHouseClientOption.COMPRESS.getKey(), "false").equalsIgnoreCase("true");
boolean clientCompression = chConfiguration.getOrDefault(ClickHouseClientOption.DECOMPRESS.getKey(), "false").equalsIgnoreCase("true");
boolean useHttpCompression = chConfiguration.getOrDefault("client.use_http_compression", "false").equalsIgnoreCase("true");
if (serverCompression || clientCompression) {
return new LZ4Entity(httpEntity, useHttpCompression, serverCompression, clientCompression,
MapUtils.getInt(chConfiguration, "compression.lz4.uncompressed_buffer_size"), isResponse);
} else {
return httpEntity;
private HttpEntity wrapEntity(HttpEntity httpEntity, int httpStatus, boolean isResponse) {

switch (httpStatus) {
case HttpStatus.SC_OK:
case HttpStatus.SC_CREATED:
case HttpStatus.SC_ACCEPTED:
case HttpStatus.SC_NO_CONTENT:
case HttpStatus.SC_PARTIAL_CONTENT:
case HttpStatus.SC_RESET_CONTENT:
case HttpStatus.SC_NOT_MODIFIED:
case HttpStatus.SC_BAD_REQUEST:
boolean serverCompression = chConfiguration.getOrDefault(ClickHouseClientOption.COMPRESS.getKey(), "false").equalsIgnoreCase("true");
boolean clientCompression = chConfiguration.getOrDefault(ClickHouseClientOption.DECOMPRESS.getKey(), "false").equalsIgnoreCase("true");
boolean useHttpCompression = chConfiguration.getOrDefault("client.use_http_compression", "false").equalsIgnoreCase("true");
if (serverCompression || clientCompression) {
return new LZ4Entity(httpEntity, useHttpCompression, serverCompression, clientCompression,
MapUtils.getInt(chConfiguration, "compression.lz4.uncompressed_buffer_size"), isResponse);
}
default:
return httpEntity;
}
}

Expand Down
Loading

0 comments on commit 85f7bae

Please sign in to comment.