Skip to content

Commit 85f7bae

Browse files
authored
Merge pull request #1892 from ClickHouse/fix_password_authentication
[client-v1,client-v2, auth] Implemented authentication via http basic auth
2 parents 714dcf8 + 842f519 commit 85f7bae

File tree

8 files changed

+265
-17
lines changed

8 files changed

+265
-17
lines changed

clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpConnection.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.net.URLEncoder;
1010
import java.nio.charset.Charset;
1111
import java.nio.charset.StandardCharsets;
12+
import java.util.Base64;
1213
import java.util.*;
1314
import java.util.Map.Entry;
1415

@@ -31,6 +32,7 @@
3132
import com.clickhouse.data.ClickHouseUtils;
3233
import com.clickhouse.logging.Logger;
3334
import com.clickhouse.logging.LoggerFactory;
35+
import org.apache.hc.core5.http.HttpHeaders;
3436

3537
public abstract class ClickHouseHttpConnection implements AutoCloseable {
3638
private static final Logger log = LoggerFactory.getLogger(ClickHouseHttpConnection.class);
@@ -231,11 +233,19 @@ protected static Map<String, String> createDefaultHeaders(ClickHouseConfig confi
231233
// TODO check if auth-scheme is available and supported
232234
map.put("authorization", credentials.getAccessToken());
233235
} else if (!hasAuthorizationHeader) {
234-
map.put("x-clickhouse-user", credentials.getUserName());
235236
if (config.isSsl() && !ClickHouseChecker.isNullOrEmpty(config.getSslCert())) {
236-
map.put("x-clickhouse-ssl-certificate-auth", "on");
237-
} else if (!ClickHouseChecker.isNullOrEmpty(credentials.getPassword())) {
238-
map.put("x-clickhouse-key", credentials.getPassword());
237+
map.put(ClickHouseHttpProto.HEADER_DB_USER, credentials.getUserName());
238+
map.put(ClickHouseHttpProto.HEADER_SSL_CERT_AUTH, "on");
239+
} else {
240+
boolean useBasicAuthentication = config.getBoolOption(ClickHouseHttpOption.USE_BASIC_AUTHENTICATION);
241+
if (useBasicAuthentication) {
242+
String password = credentials.getPassword() == null ? "" : credentials.getPassword();
243+
map.put(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder()
244+
.encodeToString((credentials.getUserName() + ":" + password).getBytes(StandardCharsets.UTF_8)));
245+
} else {
246+
map.put(ClickHouseHttpProto.HEADER_DB_USER, credentials.getUserName());
247+
map.put(ClickHouseHttpProto.HEADER_DB_PASSWORD, credentials.getPassword());
248+
}
239249
}
240250
}
241251

clickhouse-http-client/src/main/java/com/clickhouse/client/http/ClickHouseHttpProto.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ public class ClickHouseHttpProto {
4444
*/
4545
public static final String HEADER_DB_USER = "X-ClickHouse-User";
4646

47+
/**
48+
* Password of user to be used to authenticate. Note: header value should be unencoded, so using
49+
* special characters might cause issues. It is recommended to use the Basic Authentication instead.
50+
*/
4751
public static final String HEADER_DB_PASSWORD = "X-ClickHouse-Key";
4852

4953
public static final String HEADER_SSL_CERT_AUTH = "x-clickhouse-ssl-certificate-auth";

clickhouse-http-client/src/main/java/com/clickhouse/client/http/config/ClickHouseHttpOption.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,13 @@ public enum ClickHouseHttpOption implements ClickHouseOption {
107107
*/
108108
KEEP_ALIVE_TIMEOUT("alive_timeout", -1L,
109109
"Default keep-alive timeout in milliseconds."),
110-
;
110+
111+
/**
112+
* Whether to use HTTP basic authentication. Default value is true.
113+
* Password that contain UTF8 characters may not be passed through http headers and BASIC authentication
114+
* is the only option here.
115+
*/
116+
USE_BASIC_AUTHENTICATION("http_use_basic_auth", true, "Whether to use basic authentication.");
111117

112118
private final String key;
113119
private final Serializable defaultValue;

clickhouse-jdbc/src/test/java/com/clickhouse/jdbc/AccessManagementTest.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,89 @@ public void testSetRolesAccessingTableRows() throws SQLException {
176176
Assert.fail("Failed to check roles", e);
177177
}
178178
}
179+
180+
@Test(groups = "integration", dataProvider = "passwordAuthMethods")
181+
public void testPasswordAuthentication(String identifyWith, String identifyBy) throws SQLException {
182+
if (isCloud()) return; // Doesn’t allow to create users with specific passwords
183+
String url = String.format("jdbc:ch:%s", getEndpointString());
184+
Properties properties = new Properties();
185+
properties.setProperty(ClickHouseHttpOption.REMEMBER_LAST_SET_ROLES.getKey(), "true");
186+
ClickHouseDataSource dataSource = new ClickHouseDataSource(url, properties);
187+
188+
try (Connection connection = dataSource.getConnection("access_dba", "123")) {
189+
Statement st = connection.createStatement();
190+
st.execute("DROP USER IF EXISTS some_user");
191+
st.execute("CREATE USER some_user IDENTIFIED WITH " + identifyWith + " BY '" + identifyBy + "'");
192+
} catch (Exception e) {
193+
Assert.fail("Failed on setup", e);
194+
}
195+
196+
try (Connection connection = dataSource.getConnection("some_user", identifyBy)) {
197+
Statement st = connection.createStatement();
198+
ResultSet rs = st.executeQuery("SELECT user() AS user_name");
199+
Assert.assertTrue(rs.next());
200+
Assert.assertEquals(rs.getString(1), "some_user");
201+
} catch (Exception e) {
202+
Assert.fail("Failed to authenticate", e);
203+
}
204+
}
205+
206+
@DataProvider(name = "passwordAuthMethods")
207+
private static Object[][] passwordAuthMethods() {
208+
return new Object[][] {
209+
{ "plaintext_password", "password" },
210+
{ "plaintext_password", "" },
211+
{ "plaintext_password", "S3Cr=?t"},
212+
{ "plaintext_password", "123§" },
213+
{ "sha256_password", "password" },
214+
{ "sha256_password", "123§" },
215+
{ "sha256_password", "S3Cr=?t"},
216+
{ "sha256_password", "S3Cr?=t"},
217+
};
218+
}
219+
220+
@Test(groups = "integration", dataProvider = "headerAuthDataProvider")
221+
public void testSwitchingBasicAuthToClickHouseHeaders(String identifyWith, String identifyBy, boolean shouldFail) throws SQLException {
222+
if (isCloud()) return; // Doesn't allow to create users with specific passwords
223+
String url = String.format("jdbc:ch:%s", getEndpointString());
224+
Properties properties = new Properties();
225+
properties.put(ClickHouseHttpOption.USE_BASIC_AUTHENTICATION.getKey(), false);
226+
ClickHouseDataSource dataSource = new ClickHouseDataSource(url, properties);
227+
228+
try (Connection connection = dataSource.getConnection("access_dba", "123")) {
229+
Statement st = connection.createStatement();
230+
st.execute("DROP USER IF EXISTS some_user");
231+
st.execute("CREATE USER some_user IDENTIFIED WITH " + identifyWith + " BY '" + identifyBy + "'");
232+
} catch (Exception e) {
233+
Assert.fail("Failed on setup", e);
234+
}
235+
236+
try (Connection connection = dataSource.getConnection("some_user", identifyBy)) {
237+
Statement st = connection.createStatement();
238+
ResultSet rs = st.executeQuery("SELECT user() AS user_name");
239+
Assert.assertTrue(rs.next());
240+
Assert.assertEquals(rs.getString(1), "some_user");
241+
if (shouldFail) {
242+
Assert.fail("Expected authentication to fail");
243+
}
244+
} catch (Exception e) {
245+
if (!shouldFail) {
246+
Assert.fail("Failed to authenticate", e);
247+
}
248+
}
249+
}
250+
251+
@DataProvider(name = "headerAuthDataProvider")
252+
private static Object[][] headerAuthDataProvider() {
253+
return new Object[][] {
254+
{ "plaintext_password", "password", false },
255+
{ "plaintext_password", "", false },
256+
{ "plaintext_password", "S3Cr=?t", true},
257+
{ "plaintext_password", "123§", true },
258+
{ "sha256_password", "password", false},
259+
{ "sha256_password", "123§", true },
260+
{ "sha256_password", "S3Cr=?t", true},
261+
{ "sha256_password", "S3Cr?=t", false},
262+
};
263+
}
179264
}

client-v2/src/main/java/com/clickhouse/client/api/Client.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,16 @@ public Builder columnToMethodMatchingStrategy(ColumnToMethodMatchingStrategy str
876876
return this;
877877
}
878878

879+
/**
880+
* Whether to use HTTP basic authentication. Default value is true.
881+
* Password that contain UTF8 characters may not be passed through http headers and BASIC authentication
882+
* is the only option here.
883+
*/
884+
public Builder useHTTPBasicAuth(boolean useBasicAuth) {
885+
this.configuration.put(ClientSettings.HTTP_USE_BASIC_AUTH, String.valueOf(useBasicAuth));
886+
return this;
887+
}
888+
879889
public Client build() {
880890
setDefaults();
881891

@@ -1009,6 +1019,10 @@ private void setDefaults() {
10091019
if (columnToMethodMatchingStrategy == null) {
10101020
columnToMethodMatchingStrategy = DefaultColumnToMethodMatchingStrategy.INSTANCE;
10111021
}
1022+
1023+
if (!configuration.containsKey(ClientSettings.HTTP_USE_BASIC_AUTH)) {
1024+
useHTTPBasicAuth(true);
1025+
}
10121026
}
10131027
}
10141028

client-v2/src/main/java/com/clickhouse/client/api/ClientSettings.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,6 @@ public static List<String> valuesFromCommaSeparated(String value) {
3737
public static final String SESSION_DB_ROLES = "session_db_roles";
3838

3939
public static final String SETTING_LOG_COMMENT = SERVER_SETTING_PREFIX + "log_comment";
40+
41+
public static final String HTTP_USE_BASIC_AUTH = "http_use_basic_auth";
4042
}

client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
import java.net.NoRouteToHostException;
6464
import java.net.URI;
6565
import java.net.URISyntaxException;
66+
import java.net.URLEncoder;
6667
import java.net.UnknownHostException;
6768
import java.nio.charset.StandardCharsets;
6869
import java.security.NoSuchAlgorithmException;
@@ -338,13 +339,14 @@ public ClassicHttpResponse executeRequest(ClickHouseNode server, Map<String, Obj
338339
.build();
339340
req.setConfig(httpReqConfig);
340341
// setting entity. wrapping if compression is enabled
341-
req.setEntity(wrapEntity(new EntityTemplate(-1, CONTENT_TYPE, null, writeCallback), false));
342+
req.setEntity(wrapEntity(new EntityTemplate(-1, CONTENT_TYPE, null, writeCallback), HttpStatus.SC_OK, false));
342343

343344
HttpClientContext context = HttpClientContext.create();
344345

345346
try {
346347
ClassicHttpResponse httpResponse = httpClient.executeOpen(null, req, context);
347-
httpResponse.setEntity(wrapEntity(httpResponse.getEntity(), true));
348+
httpResponse.setEntity(wrapEntity(httpResponse.getEntity(), httpResponse.getCode(), true));
349+
348350
if (httpResponse.getCode() == HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED) {
349351
throw new ClientMisconfigurationException("Proxy authentication required. Please check your proxy settings.");
350352
} else if (httpResponse.getCode() == HttpStatus.SC_BAD_GATEWAY) {
@@ -395,11 +397,17 @@ private void addHeaders(HttpPost req, Map<String, String> chConfig, Map<String,
395397
req.addHeader(ClickHouseHttpProto.HEADER_QUERY_ID, requestConfig.get(ClickHouseClientOption.QUERY_ID.getKey()).toString());
396398
}
397399
}
398-
req.addHeader(ClickHouseHttpProto.HEADER_DB_USER, chConfig.get(ClickHouseDefaults.USER.getKey()));
400+
399401
if (MapUtils.getFlag(chConfig, "ssl_authentication", false)) {
402+
req.addHeader(ClickHouseHttpProto.HEADER_DB_USER, chConfig.get(ClickHouseDefaults.USER.getKey()));
400403
req.addHeader(ClickHouseHttpProto.HEADER_SSL_CERT_AUTH, "on");
404+
} else if (chConfig.getOrDefault(ClientSettings.HTTP_USE_BASIC_AUTH, "true").equalsIgnoreCase("true")) {
405+
req.addHeader(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(
406+
(chConfig.get(ClickHouseDefaults.USER.getKey()) + ":" + chConfig.get(ClickHouseDefaults.PASSWORD.getKey())).getBytes(StandardCharsets.UTF_8)));
401407
} else {
408+
req.addHeader(ClickHouseHttpProto.HEADER_DB_USER, chConfig.get(ClickHouseDefaults.USER.getKey()));
402409
req.addHeader(ClickHouseHttpProto.HEADER_DB_PASSWORD, chConfig.get(ClickHouseDefaults.PASSWORD.getKey()));
410+
403411
}
404412
if (proxyAuthHeaderValue != null) {
405413
req.addHeader(HttpHeaders.PROXY_AUTHORIZATION, proxyAuthHeaderValue);
@@ -428,6 +436,14 @@ private void addHeaders(HttpPost req, Map<String, String> chConfig, Map<String,
428436
req.addHeader(entry.getKey().substring(ClientSettings.HTTP_HEADER_PREFIX.length()), entry.getValue().toString());
429437
}
430438
}
439+
440+
// Special cases
441+
if (req.containsHeader(HttpHeaders.AUTHORIZATION) && (req.containsHeader(ClickHouseHttpProto.HEADER_DB_USER) ||
442+
req.containsHeader(ClickHouseHttpProto.HEADER_DB_PASSWORD))) {
443+
// user has set auth header for purpose, lets remove ours
444+
req.removeHeaders(ClickHouseHttpProto.HEADER_DB_USER);
445+
req.removeHeaders(ClickHouseHttpProto.HEADER_DB_PASSWORD);
446+
}
431447
}
432448
private void addQueryParams(URIBuilder req, Map<String, String> chConfig, Map<String, Object> requestConfig) {
433449
if (requestConfig == null) {
@@ -487,15 +503,26 @@ private void addQueryParams(URIBuilder req, Map<String, String> chConfig, Map<St
487503
}
488504
}
489505

490-
private HttpEntity wrapEntity(HttpEntity httpEntity, boolean isResponse) {
491-
boolean serverCompression = chConfiguration.getOrDefault(ClickHouseClientOption.COMPRESS.getKey(), "false").equalsIgnoreCase("true");
492-
boolean clientCompression = chConfiguration.getOrDefault(ClickHouseClientOption.DECOMPRESS.getKey(), "false").equalsIgnoreCase("true");
493-
boolean useHttpCompression = chConfiguration.getOrDefault("client.use_http_compression", "false").equalsIgnoreCase("true");
494-
if (serverCompression || clientCompression) {
495-
return new LZ4Entity(httpEntity, useHttpCompression, serverCompression, clientCompression,
496-
MapUtils.getInt(chConfiguration, "compression.lz4.uncompressed_buffer_size"), isResponse);
497-
} else {
498-
return httpEntity;
506+
private HttpEntity wrapEntity(HttpEntity httpEntity, int httpStatus, boolean isResponse) {
507+
508+
switch (httpStatus) {
509+
case HttpStatus.SC_OK:
510+
case HttpStatus.SC_CREATED:
511+
case HttpStatus.SC_ACCEPTED:
512+
case HttpStatus.SC_NO_CONTENT:
513+
case HttpStatus.SC_PARTIAL_CONTENT:
514+
case HttpStatus.SC_RESET_CONTENT:
515+
case HttpStatus.SC_NOT_MODIFIED:
516+
case HttpStatus.SC_BAD_REQUEST:
517+
boolean serverCompression = chConfiguration.getOrDefault(ClickHouseClientOption.COMPRESS.getKey(), "false").equalsIgnoreCase("true");
518+
boolean clientCompression = chConfiguration.getOrDefault(ClickHouseClientOption.DECOMPRESS.getKey(), "false").equalsIgnoreCase("true");
519+
boolean useHttpCompression = chConfiguration.getOrDefault("client.use_http_compression", "false").equalsIgnoreCase("true");
520+
if (serverCompression || clientCompression) {
521+
return new LZ4Entity(httpEntity, useHttpCompression, serverCompression, clientCompression,
522+
MapUtils.getInt(chConfiguration, "compression.lz4.uncompressed_buffer_size"), isResponse);
523+
}
524+
default:
525+
return httpEntity;
499526
}
500527
}
501528

0 commit comments

Comments
 (0)