mirror of
https://github.com/ClickHouse/ClickHouse.git
synced 2024-11-25 00:52:02 +00:00
better
This commit is contained in:
parent
ade30bd6d2
commit
4bdcd7a72f
@ -50,6 +50,14 @@ This command will produce a base32-encoded secret that can be added to the secre
|
||||
|
||||
To enable TOTP for a specific user, replace any existing password-based fields (like `password` or `password_sha256_hex`) with the `time_based_one_time_password` section. Only one authentication method is allowed per user, so TOTP cannot be combined with other methods such as password or LDAP.
|
||||
|
||||
The [qrencode](https://linux.die.net/man/1/qrencode) tool can be used to generate a QR code for the TOTP secret.
|
||||
|
||||
```bash
|
||||
$ qrencode -t ansiutf8 'otpauth://totp/ClickHouse?issuer=ClickHouse&secret=JBSWY3DPEHPK3PXP'
|
||||
```
|
||||
|
||||
After configuring TOTP for a user, one-time password can be used as a password for a user.
|
||||
|
||||
## Enabling TOTP Authentication using SQL {#enabling-totp-auth-using-sql}
|
||||
|
||||
When SQL-driven Access Control and Account Management is enabled, TOTP authentication can be set for users via SQL:
|
||||
|
@ -141,8 +141,7 @@ namespace
|
||||
{
|
||||
return checkOneTimePassword(
|
||||
/* password */ basic_credentials->getPassword(),
|
||||
/* secret */ authentication_method.getPassword(),
|
||||
/* config */ authentication_method.getOneTimePasswordConfig());
|
||||
/* secret */ authentication_method.getOneTimePassword());
|
||||
}
|
||||
case AuthenticationType::LDAP:
|
||||
{
|
||||
|
@ -134,7 +134,7 @@ void AuthenticationData::setPassword(const String & password_, bool validate)
|
||||
return;
|
||||
|
||||
case AuthenticationType::ONE_TIME_PASSWORD:
|
||||
setPasswordHashBinary(Util::stringToDigest(normalizeOneTimePasswordSecret(password_)), validate);
|
||||
setOneTimePassword(password_, OneTimePasswordParams{}, validate);
|
||||
return;
|
||||
|
||||
case AuthenticationType::BCRYPT_PASSWORD:
|
||||
@ -153,13 +153,13 @@ void AuthenticationData::setPassword(const String & password_, bool validate)
|
||||
throw Exception(ErrorCodes::NOT_IMPLEMENTED, "setPassword(): authentication type {} not supported", toString(type));
|
||||
}
|
||||
|
||||
void AuthenticationData::setOneTimePassword(const String & password_, OneTimePasswordConfig config, bool validate)
|
||||
void AuthenticationData::setOneTimePassword(const String & password_, OneTimePasswordParams config, bool validate)
|
||||
{
|
||||
if (type != AuthenticationType::ONE_TIME_PASSWORD)
|
||||
throw Exception(ErrorCodes::LOGICAL_ERROR, "Cannot specify one-time password for authentication type {}", toString(type));
|
||||
|
||||
otp_config = config;
|
||||
setPasswordHashBinary(Util::stringToDigest(normalizeOneTimePasswordSecret(password_)), validate);
|
||||
otp_secret.emplace(password_, config);
|
||||
setPasswordHashBinary(Util::stringToDigest(otp_secret->key), validate);
|
||||
}
|
||||
|
||||
void AuthenticationData::setPasswordBcrypt(const String & password_, int workfactor_, bool validate)
|
||||
@ -172,10 +172,13 @@ void AuthenticationData::setPasswordBcrypt(const String & password_, int workfac
|
||||
|
||||
String AuthenticationData::getPassword() const
|
||||
{
|
||||
if (type != AuthenticationType::PLAINTEXT_PASSWORD
|
||||
&& type != AuthenticationType::ONE_TIME_PASSWORD)
|
||||
throw Exception(ErrorCodes::LOGICAL_ERROR, "Cannot decode the password for authentication type {}", type);
|
||||
return String(password_hash.data(), password_hash.data() + password_hash.size());
|
||||
if (type == AuthenticationType::PLAINTEXT_PASSWORD)
|
||||
return String(password_hash.data(), password_hash.data() + password_hash.size());
|
||||
|
||||
if (type == AuthenticationType::ONE_TIME_PASSWORD && otp_secret)
|
||||
return otp_secret->key;
|
||||
|
||||
throw Exception(ErrorCodes::LOGICAL_ERROR, "Cannot decode the password for authentication type {}", type);
|
||||
}
|
||||
|
||||
|
||||
|
@ -37,8 +37,8 @@ public:
|
||||
/// Returns the password. Allowed to use only for Type::PLAINTEXT_PASSWORD.
|
||||
String getPassword() const;
|
||||
|
||||
void setOneTimePassword(const String & password_, OneTimePasswordConfig config, bool validate);
|
||||
const OneTimePasswordConfig & getOneTimePasswordConfig() const { return otp_config; }
|
||||
void setOneTimePassword(const String & password_, OneTimePasswordParams config, bool validate);
|
||||
const OneTimePasswordSecret & getOneTimePassword() const { return otp_secret.value(); }
|
||||
|
||||
/// Sets the password as a string of hexadecimal digits.
|
||||
void setPasswordHashHex(const String & hash, bool validate);
|
||||
@ -100,7 +100,7 @@ public:
|
||||
private:
|
||||
AuthenticationType type = AuthenticationType::NO_PASSWORD;
|
||||
Digest password_hash;
|
||||
OneTimePasswordConfig otp_config;
|
||||
std::optional<OneTimePasswordSecret> otp_secret;
|
||||
String ldap_server_name;
|
||||
String kerberos_realm;
|
||||
SSLCertificateSubjects ssl_certificate_subjects;
|
||||
|
@ -30,10 +30,11 @@ namespace ErrorCodes
|
||||
extern const int SUPPORT_IS_DISABLED;
|
||||
}
|
||||
|
||||
static const UInt8 b32_alphabet[] = u8"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
/// Checks if the secret contains only valid base32 characters.
|
||||
/// The secret may contain spaces, which are ignored and lower-case characters, which are converted to upper-case.
|
||||
String normalizeOneTimePasswordSecret(const String & secret)
|
||||
{
|
||||
static const UInt8 b32_alphabet[] = u8"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
static const UInt8 b32_lower_alphabet[] = u8"abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
constexpr static UInt8 CHAR_IS_VALID = 1;
|
||||
@ -67,37 +68,20 @@ String normalizeOneTimePasswordSecret(const String & secret)
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool validateBase32Secret(const String & secret)
|
||||
{
|
||||
if (secret.empty())
|
||||
return false;
|
||||
|
||||
std::array<UInt8, 128> table = {};
|
||||
for (const auto * p = b32_alphabet; *p; p++)
|
||||
table[*p] = 1;
|
||||
for (const auto c : secret)
|
||||
{
|
||||
size_t idx = static_cast<UInt8>(c);
|
||||
if (idx >= table.size() || table[idx] == 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::string_view toString(OneTimePasswordConfig::Algorithm algorithm)
|
||||
static std::string_view toString(OneTimePasswordParams::Algorithm algorithm)
|
||||
{
|
||||
switch (algorithm)
|
||||
{
|
||||
case OneTimePasswordConfig::Algorithm::SHA1: return "SHA1";
|
||||
case OneTimePasswordConfig::Algorithm::SHA256: return "SHA256";
|
||||
case OneTimePasswordConfig::Algorithm::SHA512: return "SHA512";
|
||||
case OneTimePasswordParams::Algorithm::SHA1: return "SHA1";
|
||||
case OneTimePasswordParams::Algorithm::SHA256: return "SHA256";
|
||||
case OneTimePasswordParams::Algorithm::SHA512: return "SHA512";
|
||||
}
|
||||
throw Exception(ErrorCodes::LOGICAL_ERROR, "Unknown algorithm for one-time password: {}", static_cast<UInt32>(algorithm));
|
||||
}
|
||||
|
||||
static OneTimePasswordConfig::Algorithm hashingAlgorithmFromString(const String & algorithm_name)
|
||||
static OneTimePasswordParams::Algorithm hashingAlgorithmFromString(const String & algorithm_name)
|
||||
{
|
||||
for (auto alg : {OneTimePasswordConfig::Algorithm::SHA1, OneTimePasswordConfig::Algorithm::SHA256, OneTimePasswordConfig::Algorithm::SHA512})
|
||||
for (auto alg : {OneTimePasswordParams::Algorithm::SHA1, OneTimePasswordParams::Algorithm::SHA256, OneTimePasswordParams::Algorithm::SHA512})
|
||||
{
|
||||
if (Poco::toUpper(algorithm_name) == toString(alg))
|
||||
return alg;
|
||||
@ -105,7 +89,7 @@ static OneTimePasswordConfig::Algorithm hashingAlgorithmFromString(const String
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Unknown algorithm for one-time password: '{}'", algorithm_name);
|
||||
}
|
||||
|
||||
OneTimePasswordConfig::OneTimePasswordConfig(Int32 num_digits_, Int32 period_, const String & algorithm_name_)
|
||||
OneTimePasswordParams::OneTimePasswordParams(Int32 num_digits_, Int32 period_, const String & algorithm_name_)
|
||||
{
|
||||
if (num_digits_)
|
||||
num_digits = num_digits_;
|
||||
@ -120,24 +104,28 @@ OneTimePasswordConfig::OneTimePasswordConfig(Int32 num_digits_, Int32 period_, c
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid period for one-time password: {}", period);
|
||||
}
|
||||
|
||||
std::string_view OneTimePasswordConfig::getAlgorithmName() const { return toString(algorithm); }
|
||||
std::string_view OneTimePasswordParams::getAlgorithmName() const { return toString(algorithm); }
|
||||
|
||||
String getOneTimePasswordSecretLink(const String & secret, const OneTimePasswordConfig & config)
|
||||
|
||||
OneTimePasswordSecret::OneTimePasswordSecret(const String & key_, OneTimePasswordParams params_)
|
||||
: key(normalizeOneTimePasswordSecret(key_)), params(params_)
|
||||
{
|
||||
validateBase32Secret(secret);
|
||||
|
||||
if (config == OneTimePasswordConfig{})
|
||||
return fmt::format("otpauth://totp/ClickHouse?issuer=ClickHouse&secret={}", secret);
|
||||
|
||||
return fmt::format("otpauth://totp/ClickHouse?issuer=ClickHouse&secret={}&digits={}&period={}&algorithm={}",
|
||||
secret, config.num_digits, config.period, toString(config.algorithm));
|
||||
}
|
||||
|
||||
String getOneTimePassword(const String & secret [[ maybe_unused ]], const OneTimePasswordConfig & config [[ maybe_unused ]], UInt64 current_time [[ maybe_unused ]])
|
||||
String getOneTimePasswordSecretLink(const OneTimePasswordSecret & secret)
|
||||
{
|
||||
if (secret.params == OneTimePasswordParams{})
|
||||
return fmt::format("otpauth://totp/ClickHouse?issuer=ClickHouse&secret={}", secret.key);
|
||||
|
||||
return fmt::format("otpauth://totp/ClickHouse?issuer=ClickHouse&secret={}&digits={}&period={}&algorithm={}",
|
||||
secret.key, secret.params.num_digits, secret.params.period, toString(secret.params.algorithm));
|
||||
}
|
||||
|
||||
String getOneTimePassword(const String & secret [[ maybe_unused ]], const OneTimePasswordParams & config [[ maybe_unused ]], UInt64 current_time [[ maybe_unused ]])
|
||||
{
|
||||
#if USE_SSL
|
||||
int sha_algo = config.algorithm == OneTimePasswordConfig::Algorithm::SHA512 ? TOTP_SHA512
|
||||
: config.algorithm == OneTimePasswordConfig::Algorithm::SHA256 ? TOTP_SHA256
|
||||
int sha_algo = config.algorithm == OneTimePasswordParams::Algorithm::SHA512 ? TOTP_SHA512
|
||||
: config.algorithm == OneTimePasswordParams::Algorithm::SHA256 ? TOTP_SHA256
|
||||
: TOTP_SHA1;
|
||||
|
||||
cotp_error_t error;
|
||||
@ -153,18 +141,16 @@ String getOneTimePassword(const String & secret [[ maybe_unused ]], const OneTim
|
||||
}
|
||||
|
||||
|
||||
bool checkOneTimePassword(const String & password, const String & secret, const OneTimePasswordConfig & config)
|
||||
bool checkOneTimePassword(const String & password, const OneTimePasswordSecret & secret)
|
||||
{
|
||||
if (password.size() != static_cast<size_t>(config.num_digits)
|
||||
if (password.size() != static_cast<size_t>(secret.params.num_digits)
|
||||
|| !std::all_of(password.begin(), password.end(), isdigit))
|
||||
return false;
|
||||
|
||||
validateBase32Secret(secret);
|
||||
|
||||
auto current_time = static_cast<UInt64>(std::time(nullptr));
|
||||
for (int delta : {0, -1, 1})
|
||||
{
|
||||
if (password == getOneTimePassword(secret, config, current_time + delta * config.period))
|
||||
if (password == getOneTimePassword(secret.key, secret.params, current_time + delta * secret.params.period))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -5,7 +5,7 @@
|
||||
namespace DB
|
||||
{
|
||||
|
||||
struct OneTimePasswordConfig
|
||||
struct OneTimePasswordParams
|
||||
{
|
||||
Int32 num_digits = 6;
|
||||
Int32 period = 30;
|
||||
@ -17,18 +17,24 @@ struct OneTimePasswordConfig
|
||||
SHA512,
|
||||
} algorithm = Algorithm::SHA1;
|
||||
|
||||
explicit OneTimePasswordConfig(Int32 num_digits_ = 0, Int32 period_ = 0, const String & algorithm_name_ = "");
|
||||
explicit OneTimePasswordParams(Int32 num_digits_ = {}, Int32 period_ = {}, const String & algorithm_name_ = {});
|
||||
|
||||
bool operator==(const OneTimePasswordConfig &) const = default;
|
||||
bool operator==(const OneTimePasswordParams &) const = default;
|
||||
|
||||
std::string_view getAlgorithmName() const;
|
||||
};
|
||||
|
||||
String getOneTimePasswordSecretLink(const String & secret, const OneTimePasswordConfig & config);
|
||||
bool checkOneTimePassword(const String & password, const String & secret, const OneTimePasswordConfig & config);
|
||||
struct OneTimePasswordSecret
|
||||
{
|
||||
String key;
|
||||
OneTimePasswordParams params;
|
||||
|
||||
/// Checks if the secret contains only valid base32 characters.
|
||||
/// The secret may contain spaces, which are ignored and lower-case characters, which are converted to upper-case.
|
||||
String normalizeOneTimePasswordSecret(const String & secret);
|
||||
explicit OneTimePasswordSecret(
|
||||
const String & key_,
|
||||
OneTimePasswordParams params_ = OneTimePasswordParams{});
|
||||
};
|
||||
|
||||
String getOneTimePasswordSecretLink(const OneTimePasswordSecret & secret);
|
||||
bool checkOneTimePassword(const String & password, const OneTimePasswordSecret & secret);
|
||||
|
||||
}
|
||||
|
@ -176,11 +176,10 @@ namespace
|
||||
else if (has_otp_secret)
|
||||
{
|
||||
String secret = config.getString(user_config + ".time_based_one_time_password.secret", "");
|
||||
OneTimePasswordConfig otp_config(
|
||||
OneTimePasswordParams otp_config(
|
||||
config.getInt(user_config + ".time_based_one_time_password.digits", {}),
|
||||
config.getInt(user_config + ".time_based_one_time_password.period", {}),
|
||||
config.getString(user_config + ".time_based_one_time_password.algorithm", "")
|
||||
);
|
||||
config.getString(user_config + ".time_based_one_time_password.algorithm", {}));
|
||||
user->authentication_methods.emplace_back(AuthenticationType::ONE_TIME_PASSWORD).setOneTimePassword(secret, otp_config, validate);
|
||||
}
|
||||
else if (has_ldap)
|
||||
|
@ -151,12 +151,12 @@ void StorageSystemUsers::fillData(MutableColumns & res_columns, ContextPtr conte
|
||||
}
|
||||
else if (auth_data.getType() == AuthenticationType::ONE_TIME_PASSWORD)
|
||||
{
|
||||
const auto & config = auth_data.getOneTimePasswordConfig();
|
||||
if (config != OneTimePasswordConfig{})
|
||||
const auto & secret = auth_data.getOneTimePassword();
|
||||
if (secret.params != OneTimePasswordParams{})
|
||||
{
|
||||
auth_params_json.set("algorithm", toString(config.algorithm));
|
||||
auth_params_json.set("num_digits", toString(config.num_digits));
|
||||
auth_params_json.set("period", toString(config.period));
|
||||
auth_params_json.set("algorithm", toString(secret.params.algorithm));
|
||||
auth_params_json.set("num_digits", toString(secret.params.num_digits));
|
||||
auth_params_json.set("period", toString(secret.params.period));
|
||||
}
|
||||
}
|
||||
else if (auth_data.getType() == AuthenticationType::SSL_CERTIFICATE)
|
||||
|
@ -1,23 +0,0 @@
|
||||
<clickhouse>
|
||||
<profiles>
|
||||
<default>
|
||||
</default>
|
||||
</profiles>
|
||||
<users>
|
||||
<totuser>
|
||||
<time_based_one_time_password>
|
||||
<secret>inwg sy3l jbxx k43f biaa ====</secret>
|
||||
<period>10</period>
|
||||
<digits>9</digits>
|
||||
<algorithm>SHA1</algorithm>
|
||||
</time_based_one_time_password>
|
||||
|
||||
<access_management>1</access_management>
|
||||
<networks replace="replace">
|
||||
<ip>::/0</ip>
|
||||
</networks>
|
||||
<profile>default</profile>
|
||||
<quota>default</quota>
|
||||
</totuser>
|
||||
</users>
|
||||
</clickhouse>
|
@ -1,6 +1,8 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
from fnmatch import fnmatch
|
||||
@ -9,6 +11,9 @@ import pytest
|
||||
|
||||
from helpers.cluster import ClickHouseCluster
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
TOTP_SECRET = base64.b32encode(random.randbytes(random.randint(3, 64))).decode()
|
||||
|
||||
cluster = ClickHouseCluster(__file__)
|
||||
node = cluster.add_instance(
|
||||
"node",
|
||||
@ -16,20 +21,54 @@ node = cluster.add_instance(
|
||||
)
|
||||
|
||||
|
||||
def create_config(totp_secret):
|
||||
config = f"""
|
||||
<clickhouse>
|
||||
<profiles>
|
||||
<default>
|
||||
</default>
|
||||
</profiles>
|
||||
<users>
|
||||
<totuser>
|
||||
<time_based_one_time_password>
|
||||
<secret>{totp_secret}</secret>
|
||||
<period>10</period>
|
||||
<digits>9</digits>
|
||||
<algorithm>SHA256</algorithm>
|
||||
</time_based_one_time_password>
|
||||
|
||||
<access_management>1</access_management>
|
||||
<networks replace="replace">
|
||||
<ip>::/0</ip>
|
||||
</networks>
|
||||
<profile>default</profile>
|
||||
<quota>default</quota>
|
||||
</totuser>
|
||||
</users>
|
||||
</clickhouse>
|
||||
""".lstrip()
|
||||
|
||||
with open(os.path.join(SCRIPT_DIR, "config/users.xml"), "w") as f:
|
||||
f.write(config)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def started_cluster():
|
||||
try:
|
||||
create_config(TOTP_SECRET)
|
||||
cluster.start()
|
||||
yield cluster
|
||||
finally:
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
def generate_totp(secret, interval=30, digits=6, timepoint=None):
|
||||
def generate_totp(
|
||||
secret, interval=30, digits=6, sha_version=hashlib.sha1, timepoint=None
|
||||
):
|
||||
key = base64.b32decode(secret, casefold=True)
|
||||
time_step = int(timepoint or time.time() / interval)
|
||||
msg = struct.pack(">Q", time_step)
|
||||
hmac_hash = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
hmac_hash = hmac.new(key, msg, sha_version).digest()
|
||||
offset = hmac_hash[-1] & 0x0F
|
||||
binary_code = struct.unpack(">I", hmac_hash[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
otp = binary_code % (10**digits)
|
||||
@ -38,7 +77,12 @@ def generate_totp(secret, interval=30, digits=6, timepoint=None):
|
||||
|
||||
def test_one_time_password(started_cluster):
|
||||
query_text = "SELECT currentUser() || toString(42)"
|
||||
totuser_secret = {"secret": "INWGSY3LJBXXK43FBIAA====", "interval": 10, "digits": 9}
|
||||
totuser_secret = {
|
||||
"secret": TOTP_SECRET,
|
||||
"interval": 10,
|
||||
"digits": 9,
|
||||
"sha_version": hashlib.sha256,
|
||||
}
|
||||
|
||||
old_password = generate_totp(
|
||||
**totuser_secret, timepoint=time.time() - 3 * totuser_secret["interval"]
|
||||
@ -75,8 +119,9 @@ def test_one_time_password(started_cluster):
|
||||
)
|
||||
assert fnmatch(err_resp, f"*{error_message}*BAD_ARGUMENTS*"), err_resp
|
||||
|
||||
# lowercase secret with spaces is allowed
|
||||
node.query(
|
||||
"CREATE USER user2 IDENTIFIED WITH one_time_password BY 'WLFVSILVT3PKZPKONCMGAHN7KBPTUX2J'",
|
||||
"CREATE USER user2 IDENTIFIED WITH one_time_password BY 'inwg sy3l jbxx k43f biaa'",
|
||||
user="totuser",
|
||||
password=generate_totp(**totuser_secret),
|
||||
)
|
||||
@ -84,7 +129,7 @@ def test_one_time_password(started_cluster):
|
||||
assert "user242\n" == node.query(
|
||||
query_text,
|
||||
user="user2",
|
||||
password=generate_totp("WLFVSILVT3PKZPKONCMGAHN7KBPTUX2J"),
|
||||
password=generate_totp("INWGSY3LJBXXK43FBIAA===="),
|
||||
)
|
||||
|
||||
resp = node.query(
|
||||
@ -101,5 +146,5 @@ def test_one_time_password(started_cluster):
|
||||
user="totuser",
|
||||
password=generate_totp(**totuser_secret),
|
||||
).splitlines()
|
||||
assert resp[0].startswith("totuser\tone_time_password\tSHA1\t9\t10"), resp
|
||||
assert resp[0].startswith("totuser\tone_time_password\tSHA256\t9\t10"), resp
|
||||
assert resp[1].startswith("user2\tone_time_password"), resp
|
||||
|
Loading…
Reference in New Issue
Block a user