diff --git a/.gitmodules b/.gitmodules index a346c23631f..a12085e4530 100644 --- a/.gitmodules +++ b/.gitmodules @@ -375,3 +375,6 @@ [submodule "contrib/postgres"] path = contrib/postgres url = https://github.com/ClickHouse/postgres.git +[submodule "contrib/libcotp"] + path = contrib/libcotp + url = https://github.com/paolostivanin/libcotp.git diff --git a/contrib/CMakeLists.txt b/contrib/CMakeLists.txt index fcec9132cb7..38b9e36dc27 100644 --- a/contrib/CMakeLists.txt +++ b/contrib/CMakeLists.txt @@ -223,6 +223,8 @@ add_contrib (google-cloud-cpp-cmake google-cloud-cpp) # requires grpc, protobuf, add_contrib (jwt-cpp-cmake jwt-cpp) +add_contrib (libcotp-cmake libcotp) + # Put all targets defined here and in subdirectories under "contrib/" folders in GUI-based IDEs. # Some of third-party projects may override CMAKE_FOLDER or FOLDER property of their targets, so they would not appear # in "contrib/..." as originally planned, so we workaround this by fixing FOLDER properties of all targets manually, diff --git a/contrib/libcotp b/contrib/libcotp new file mode 160000 index 00000000000..78a3783ac19 --- /dev/null +++ b/contrib/libcotp @@ -0,0 +1 @@ +Subproject commit 78a3783ac19604e9e3ad7053f1c43c761066bfb4 diff --git a/contrib/libcotp-cmake/CMakeLists.txt b/contrib/libcotp-cmake/CMakeLists.txt new file mode 100644 index 00000000000..1c90c9eaea3 --- /dev/null +++ b/contrib/libcotp-cmake/CMakeLists.txt @@ -0,0 +1,22 @@ +if(NOT ENABLE_SSL) + message(STATUS "libcotp: DISABLED because ENABLE_SSL=OFF") + return() +endif() + +set (LIBCOTP_SOURCE_DIR "${ClickHouse_SOURCE_DIR}/contrib/libcotp") +set (LIBCOTP_BINARY_DIR "${ClickHouse_BINARY_DIR}/contrib/libcotp") + +set(SRCS + "${LIBCOTP_SOURCE_DIR}/src/otp.c" + "${LIBCOTP_SOURCE_DIR}/src/utils/base32.c" + "${LIBCOTP_SOURCE_DIR}/src/utils/whmac_openssl.c" +) + +add_library (_libcotp ${SRCS}) + +file(MAKE_DIRECTORY "${LIBCOTP_BINARY_DIR}/include") +file(COPY "${LIBCOTP_SOURCE_DIR}/src/" DESTINATION "${LIBCOTP_BINARY_DIR}/include" FILES_MATCHING PATTERN "*.h") +target_include_directories(_libcotp SYSTEM BEFORE PUBLIC "${LIBCOTP_BINARY_DIR}/include") +target_link_libraries(_libcotp PRIVATE OpenSSL::Crypto) + +add_library(ch_contrib::libcotp ALIAS _libcotp) diff --git a/docs/en/operations/external-authenticators/index.md b/docs/en/operations/external-authenticators/index.md index f644613641c..f87db8834e8 100644 --- a/docs/en/operations/external-authenticators/index.md +++ b/docs/en/operations/external-authenticators/index.md @@ -16,4 +16,5 @@ The following external authenticators and directories are supported: - [LDAP](./ldap.md#external-authenticators-ldap) [Authenticator](./ldap.md#ldap-external-authenticator) and [Directory](./ldap.md#ldap-external-user-directory) - Kerberos [Authenticator](./kerberos.md#external-authenticators-kerberos) - [SSL X.509 authentication](./ssl-x509.md#ssl-external-authentication) -- HTTP [Authenticator](./http.md) \ No newline at end of file +- HTTP [Authenticator](./http.md) +- [Time-based one-time password](./totp.md) diff --git a/docs/en/operations/external-authenticators/totp.md b/docs/en/operations/external-authenticators/totp.md new file mode 100644 index 00000000000..d75f9599caa --- /dev/null +++ b/docs/en/operations/external-authenticators/totp.md @@ -0,0 +1,69 @@ +--- +slug: /en/operations/external-authenticators/totp +title: "TOTP" +--- +import SelfManaged from '@site/docs/en/_snippets/_self_managed_only_no_roadmap.md'; + + + +Time-Based One-Time Password (TOTP) can be used to authenticate ClickHouse users by generating temporary access codes that are valid for a limited time. +In current implementation it is a standalone authentication method, rather than a second factor for password-based authentication. +This TOTP authentication method aligns with [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) standards, making it compatible with popular TOTP applications like Google Authenticator, 1Password and similar tools. + +## TOTP Authentication Configuration {#totp-auth-configuration} + +To enable TOTP authentication for a user, configure the `time_based_one_time_password` section in `users.xml`. This section defines the TOTP settings, such as secret, validity period, number of digits, and hash algorithm. + +**Example** +```xml + + + + + + + JBSWY3DPEHPK3PXP + 30 + 6 + SHA1 + + + + + +Parameters: + +- secret - (Required) The base32-encoded secret key used to generate TOTP codes. +- period - Optional. Sets the validity period of each OTP in seconds. Must be a positive number not exceeding 120. Default is 30. +- digits - Optional. Specifies the number of digits in each OTP. Must be between 4 and 10. Default is 6. +- algorithm - Optional. Defines the hash algorithm for generating OTPs. Supported values are SHA1, SHA256, and SHA512. Default is SHA1. + +Generating a TOTP Secret + +To generate a TOTP-compatible secret for use with ClickHouse, run the following command in the terminal: + +```bash +$ base32 -w32 < /dev/urandom | head -1 +``` + +This command will produce a base32-encoded secret that can be added to the secret field in users.xml. + +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: + +```SQL +CREATE USER my_user IDENTIFIED WITH one_time_password BY 'JBSWY3DPEHPK3PXP'; +``` + +Values for `period`, `digits`, and `algorithm` will be set to their default values. diff --git a/programs/server/users.xml b/programs/server/users.xml index 57bc6309a54..1b148af7cf6 100644 --- a/programs/server/users.xml +++ b/programs/server/users.xml @@ -32,6 +32,24 @@ If you want to specify double SHA1, place it in 'password_double_sha1_hex' element. Example: e395796d6546b1b65db9d665cd43f0e858dd4303 + If you want to specify secret for one time passwords, place it in 'time_based_one_time_password' element. + Example: + + ALLT7V6M3NNSQXMEMPEAUSX77OOUTBSO + 30 + 6 + SHA1 + + + secret - base32 encoded secret key + period - password validity duration in seconds, default is 30 + digits - number of digits in password, default is 6 + algorithm - hash algorithm: SHA1/SHA256/SHA512, default is SHA1 + + How to generate secret: + Execute: echo `base32 < /dev/urandom | head -c32` + More info: https://datatracker.ietf.org/doc/html/rfc6238 + If you want to specify a previously defined LDAP server (see 'ldap_servers' in the main config) for authentication, place its name in 'server' element inside 'ldap' element. Example: my_ldap_server diff --git a/src/Access/AccessControl.cpp b/src/Access/AccessControl.cpp index 1583ccecf94..e7d45ec3cb1 100644 --- a/src/Access/AccessControl.cpp +++ b/src/Access/AccessControl.cpp @@ -611,7 +611,7 @@ AuthResult AccessControl::authenticate(const Credentials & credentials, const Po tryLogCurrentException(getLogger(), "from: " + address.toString() + ", user: " + credentials.getUserName() + ": Authentication failed", LogsLevel::information); WriteBufferFromOwnString message; - message << credentials.getUserName() << ": Authentication failed: password is incorrect, or there is no user with such name."; + message << credentials.getUserName() << ": Authentication failed: password is incorrect, or there is no user with such name"; /// Better exception message for usability. /// It is typical when users install ClickHouse, type some password and instantly forget it. diff --git a/src/Access/Authentication.cpp b/src/Access/Authentication.cpp index 1d69a659cd6..7a31ee31c86 100644 --- a/src/Access/Authentication.cpp +++ b/src/Access/Authentication.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include "config.h" @@ -136,6 +137,12 @@ namespace { return checkPasswordDoubleSHA1(basic_credentials->getPassword(), authentication_method.getPasswordHashBinary()); } + case AuthenticationType::ONE_TIME_PASSWORD: + { + return checkOneTimePassword( + /* password */ basic_credentials->getPassword(), + /* secret */ authentication_method.getOneTimePassword()); + } case AuthenticationType::LDAP: { return external_authenticators.checkLDAPCredentials(authentication_method.getLDAPServerName(), *basic_credentials); diff --git a/src/Access/AuthenticationData.cpp b/src/Access/AuthenticationData.cpp index 37a4e356af8..5e61cad8816 100644 --- a/src/Access/AuthenticationData.cpp +++ b/src/Access/AuthenticationData.cpp @@ -138,6 +138,10 @@ void AuthenticationData::setPassword(const String & password_, bool validate) setPasswordHashBinary(Util::encodeDoubleSHA1(password_), validate); return; + case AuthenticationType::ONE_TIME_PASSWORD: + setOneTimePassword(password_, OneTimePasswordParams{}, validate); + return; + case AuthenticationType::BCRYPT_PASSWORD: case AuthenticationType::NO_PASSWORD: case AuthenticationType::LDAP: @@ -154,6 +158,15 @@ 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_, 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_secret.emplace(password_, config); + setPasswordHashBinary(Util::stringToDigest(otp_secret->key), validate); +} + void AuthenticationData::setPasswordBcrypt(const String & password_, int workfactor_, bool validate) { if (type != AuthenticationType::BCRYPT_PASSWORD) @@ -164,9 +177,13 @@ void AuthenticationData::setPasswordBcrypt(const String & password_, int workfac String AuthenticationData::getPassword() const { - if (type != AuthenticationType::PLAINTEXT_PASSWORD) - throw Exception(ErrorCodes::LOGICAL_ERROR, "Cannot decode the password"); - 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); } @@ -210,6 +227,12 @@ void AuthenticationData::setPasswordHashBinary(const Digest & hash, bool validat return; } + case AuthenticationType::ONE_TIME_PASSWORD: + { + password_hash = hash; + return; + } + case AuthenticationType::SHA256_PASSWORD: { if (hash.size() != 32) @@ -312,6 +335,12 @@ std::shared_ptr AuthenticationData::toAST() const node->children.push_back(std::make_shared(getPassword())); break; } + case AuthenticationType::ONE_TIME_PASSWORD: + { + node->contains_password = true; + node->children.push_back(std::make_shared(getPassword())); + break; + } case AuthenticationType::SHA256_PASSWORD: { node->contains_hash = true; diff --git a/src/Access/AuthenticationData.h b/src/Access/AuthenticationData.h index 2d8d008c925..3dddfc32b4e 100644 --- a/src/Access/AuthenticationData.h +++ b/src/Access/AuthenticationData.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,9 @@ public: /// Returns the password. Allowed to use only for Type::PLAINTEXT_PASSWORD. String getPassword() const; + 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); String getPasswordHashHex() const; @@ -99,6 +103,7 @@ public: private: AuthenticationType type = AuthenticationType::NO_PASSWORD; Digest password_hash; + std::optional otp_secret; String ldap_server_name; String kerberos_realm; SSLCertificateSubjects ssl_certificate_subjects; diff --git a/src/Access/Common/AuthenticationType.cpp b/src/Access/Common/AuthenticationType.cpp index 427765b8a79..fe1a7382c2e 100644 --- a/src/Access/Common/AuthenticationType.cpp +++ b/src/Access/Common/AuthenticationType.cpp @@ -42,6 +42,11 @@ const AuthenticationTypeInfo & AuthenticationTypeInfo::get(AuthenticationType ty static const auto info = make_info(Keyword::DOUBLE_SHA1_PASSWORD, true); return info; } + case AuthenticationType::ONE_TIME_PASSWORD: + { + static const auto info = make_info(Keyword::ONE_TIME_PASSWORD, true); + return info; + } case AuthenticationType::LDAP: { static const auto info = make_info(Keyword::LDAP); diff --git a/src/Access/Common/AuthenticationType.h b/src/Access/Common/AuthenticationType.h index 16f4388bbff..00178d56d54 100644 --- a/src/Access/Common/AuthenticationType.h +++ b/src/Access/Common/AuthenticationType.h @@ -21,6 +21,10 @@ enum class AuthenticationType : uint8_t /// This kind of hash is used by the `mysql_native_password` authentication plugin. DOUBLE_SHA1_PASSWORD, + /// A secret is stored instead of a password + /// Users are authenticated using time-based one-time passwords + ONE_TIME_PASSWORD, + /// Password is checked by a [remote] LDAP server. Connection will be made at each authentication attempt. LDAP, diff --git a/src/Access/Common/CMakeLists.txt b/src/Access/Common/CMakeLists.txt index 6a7682ec4bd..a748af04277 100644 --- a/src/Access/Common/CMakeLists.txt +++ b/src/Access/Common/CMakeLists.txt @@ -3,3 +3,7 @@ include("${ClickHouse_SOURCE_DIR}/cmake/dbms_glob_sources.cmake") add_headers_and_sources(clickhouse_common_access .) add_library(clickhouse_common_access ${clickhouse_common_access_headers} ${clickhouse_common_access_sources}) target_link_libraries(clickhouse_common_access PUBLIC clickhouse_common_io) + +if (TARGET ch_contrib::libcotp) + target_link_libraries (clickhouse_common_access PRIVATE ch_contrib::libcotp) +endif() diff --git a/src/Access/Common/OneTimePassword.cpp b/src/Access/Common/OneTimePassword.cpp new file mode 100644 index 00000000000..d7d8c8d5f13 --- /dev/null +++ b/src/Access/Common/OneTimePassword.cpp @@ -0,0 +1,161 @@ +#include +#include +#include +#include +#include +#include + +#include "config.h" + +#if USE_SSL + +#include + +constexpr int TOTP_SHA512 = SHA512; +constexpr int TOTP_SHA256 = SHA256; +constexpr int TOTP_SHA1 = SHA1; +#undef SHA512 +#undef SHA256 +#undef SHA1 + +#endif + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; + extern const int LOGICAL_ERROR; + extern const int SUPPORT_IS_DISABLED; +} + +/// 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; + constexpr static UInt8 CHAR_IS_LOWER = 2; + std::array table = {}; + for (const auto * p = b32_alphabet; *p; p++) + table[*p] = CHAR_IS_VALID; + for (const auto * p = b32_lower_alphabet; *p; p++) + table[*p] = CHAR_IS_LOWER; + + String result = secret; + size_t i = 0; + size_t n = 0; + for (; i < secret.size(); ++i) + { + if (secret[i] == ' ' || secret[i] == '=') + continue; + size_t idx = static_cast(secret[i]); + if (idx >= table.size() || table[idx] == 0) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid character in base32 secret: '{}'", secret[i]); + if (table[idx] == CHAR_IS_VALID) + result[n] = secret[i]; + if (table[idx] == CHAR_IS_LOWER) + result[n] = std::toupper(secret[i]); + ++n; + } + result.resize(n); + + if (result.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Empty secret for one-time password"); + return result; +} + +static std::string_view toString(OneTimePasswordParams::Algorithm algorithm) +{ + switch (algorithm) + { + 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(algorithm)); +} + +static OneTimePasswordParams::Algorithm hashingAlgorithmFromString(const String & algorithm_name) +{ + for (auto alg : {OneTimePasswordParams::Algorithm::SHA1, OneTimePasswordParams::Algorithm::SHA256, OneTimePasswordParams::Algorithm::SHA512}) + { + if (Poco::toUpper(algorithm_name) == toString(alg)) + return alg; + } + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Unknown algorithm for one-time password: '{}'", algorithm_name); +} + +OneTimePasswordParams::OneTimePasswordParams(Int32 num_digits_, Int32 period_, const String & algorithm_name_) +{ + if (num_digits_) + num_digits = num_digits_; + if (period_) + period = period_; + if (!algorithm_name_.empty()) + algorithm = hashingAlgorithmFromString(algorithm_name_); + + if (num_digits < 4 || 10 < num_digits) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid number of digits for one-time password: {}", num_digits); + if (period <= 0 || 120 < period) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid period for one-time password: {}", period); +} + +std::string_view OneTimePasswordParams::getAlgorithmName() const { return toString(algorithm); } + + +OneTimePasswordSecret::OneTimePasswordSecret(const String & key_, OneTimePasswordParams params_) + : key(normalizeOneTimePasswordSecret(key_)), params(params_) +{ +} + +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)); +} + +struct CStringDeleter { void operator()(char * ptr) const { std::free(ptr); } }; + +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 == OneTimePasswordParams::Algorithm::SHA512 ? TOTP_SHA512 + : config.algorithm == OneTimePasswordParams::Algorithm::SHA256 ? TOTP_SHA256 + : TOTP_SHA1; + + cotp_error_t error; + auto result = std::unique_ptr(get_totp_at(secret.c_str(), current_time, config.num_digits, config.period, sha_algo, &error)); + + if (result == nullptr || (error != NO_ERROR && error != VALID)) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Error while retrieving one-time password, code: {}", + static_cast>(error)); + return String(result.get(), strlen(result.get())); +#else + throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "One-time password support is disabled, because ClickHouse was built without openssl library"); +#endif +} + + +bool checkOneTimePassword(const String & password, const OneTimePasswordSecret & secret) +{ + if (password.size() != static_cast(secret.params.num_digits) + || !std::all_of(password.begin(), password.end(), isdigit)) + return false; + + auto current_time = static_cast(std::time(nullptr)); + for (int delta : {0, -1, 1}) + { + if (password == getOneTimePassword(secret.key, secret.params, current_time + delta * secret.params.period)) + return true; + } + return false; +} + +} diff --git a/src/Access/Common/OneTimePassword.h b/src/Access/Common/OneTimePassword.h new file mode 100644 index 00000000000..9bd49792d21 --- /dev/null +++ b/src/Access/Common/OneTimePassword.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace DB +{ + +struct OneTimePasswordParams +{ + Int32 num_digits = 6; + Int32 period = 30; + + enum class Algorithm : UInt8 + { + SHA1, + SHA256, + SHA512, + } algorithm = Algorithm::SHA1; + + explicit OneTimePasswordParams(Int32 num_digits_ = {}, Int32 period_ = {}, const String & algorithm_name_ = {}); + + bool operator==(const OneTimePasswordParams &) const = default; + + std::string_view getAlgorithmName() const; +}; + +struct OneTimePasswordSecret +{ + String key; + OneTimePasswordParams params; + + explicit OneTimePasswordSecret( + const String & key_, + OneTimePasswordParams params_ = OneTimePasswordParams{}); +}; + +String getOneTimePasswordSecretLink(const OneTimePasswordSecret & secret); +bool checkOneTimePassword(const String & password, const OneTimePasswordSecret & secret); + +} diff --git a/src/Access/UsersConfigAccessStorage.cpp b/src/Access/UsersConfigAccessStorage.cpp index eddc7ca1e0e..c2283db08d1 100644 --- a/src/Access/UsersConfigAccessStorage.cpp +++ b/src/Access/UsersConfigAccessStorage.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -127,6 +129,7 @@ namespace String user_config = "users." + user_name; bool has_no_password = config.has(user_config + ".no_password"); bool has_password_plaintext = config.has(user_config + ".password"); + bool has_otp_secret = config.has(user_config + ".time_based_one_time_password"); bool has_password_sha256_hex = config.has(user_config + ".password_sha256_hex"); bool has_password_double_sha1_hex = config.has(user_config + ".password_double_sha1_hex"); bool has_ldap = config.has(user_config + ".ldap"); @@ -142,11 +145,11 @@ namespace bool has_http_auth = config.has(http_auth_config); size_t num_password_fields = has_no_password + has_password_plaintext + has_password_sha256_hex + has_password_double_sha1_hex - + has_ldap + has_kerberos + has_certificates + has_ssh_keys + has_http_auth; + + has_ldap + has_kerberos + has_certificates + has_ssh_keys + has_http_auth + has_otp_secret; if (num_password_fields > 1) throw Exception(ErrorCodes::BAD_ARGUMENTS, "More than one field of 'password', 'password_sha256_hex', " - "'password_double_sha1_hex', 'no_password', 'ldap', 'kerberos', 'ssl_certificates', 'ssh_keys', " + "'password_double_sha1_hex', 'no_password', 'time_based_one_time_password', 'ldap', 'kerberos', 'ssl_certificates', 'ssh_keys', " "'http_authentication' are used to specify authentication info for user {}. " "Must be only one of them.", user_name); @@ -170,6 +173,15 @@ namespace user->authentication_methods.emplace_back(AuthenticationType::DOUBLE_SHA1_PASSWORD); user->authentication_methods.back().setPasswordHashHex(config.getString(user_config + ".password_double_sha1_hex"), validate); } + else if (has_otp_secret) + { + String secret = config.getString(user_config + ".time_based_one_time_password.secret", ""); + 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", {})); + user->authentication_methods.emplace_back(AuthenticationType::ONE_TIME_PASSWORD).setOneTimePassword(secret, otp_config, validate); + } else if (has_ldap) { bool has_ldap_server = config.has(user_config + ".ldap.server"); @@ -269,7 +281,7 @@ namespace } else { - user->authentication_methods.emplace_back(); + user->authentication_methods.emplace_back(AuthenticationType::NO_PASSWORD); } for (const auto & authentication_method : user->authentication_methods) diff --git a/src/Interpreters/SessionLog.cpp b/src/Interpreters/SessionLog.cpp index afbd4ed45a1..88d86b41b3b 100644 --- a/src/Interpreters/SessionLog.cpp +++ b/src/Interpreters/SessionLog.cpp @@ -85,6 +85,7 @@ ColumnsDescription SessionLogElement::getColumnsDescription() AUTH_TYPE_NAME_AND_VALUE(AuthType::PLAINTEXT_PASSWORD), AUTH_TYPE_NAME_AND_VALUE(AuthType::SHA256_PASSWORD), AUTH_TYPE_NAME_AND_VALUE(AuthType::DOUBLE_SHA1_PASSWORD), + AUTH_TYPE_NAME_AND_VALUE(AuthType::ONE_TIME_PASSWORD), AUTH_TYPE_NAME_AND_VALUE(AuthType::LDAP), AUTH_TYPE_NAME_AND_VALUE(AuthType::JWT), AUTH_TYPE_NAME_AND_VALUE(AuthType::KERBEROS), @@ -94,7 +95,7 @@ ColumnsDescription SessionLogElement::getColumnsDescription() AUTH_TYPE_NAME_AND_VALUE(AuthType::HTTP), }); #undef AUTH_TYPE_NAME_AND_VALUE - static_assert(static_cast(AuthenticationType::MAX) == 11); + static_assert(static_cast(AuthenticationType::MAX) == 12); auto interface_type_column = std::make_shared( DataTypeEnum8::Values diff --git a/src/Parsers/Access/ASTAuthenticationData.cpp b/src/Parsers/Access/ASTAuthenticationData.cpp index c7a6429f6aa..7a76d9ba572 100644 --- a/src/Parsers/Access/ASTAuthenticationData.cpp +++ b/src/Parsers/Access/ASTAuthenticationData.cpp @@ -104,6 +104,12 @@ void ASTAuthenticationData::formatImpl(const FormatSettings & settings, FormatSt password = true; break; } + case AuthenticationType::ONE_TIME_PASSWORD: + { + prefix = "BY"; + password = true; + break; + } case AuthenticationType::JWT: { prefix = "CLAIMS"; diff --git a/src/Parsers/CommonParsers.h b/src/Parsers/CommonParsers.h index 3927ac96b2f..061a9ac7699 100644 --- a/src/Parsers/CommonParsers.h +++ b/src/Parsers/CommonParsers.h @@ -563,6 +563,7 @@ namespace DB MR_MACROS(DOUBLE_SHA1_PASSWORD, "DOUBLE_SHA1_PASSWORD") \ MR_MACROS(IS_OBJECT_ID, "IS_OBJECT_ID") \ MR_MACROS(NO_PASSWORD, "NO_PASSWORD") \ + MR_MACROS(ONE_TIME_PASSWORD, "ONE_TIME_PASSWORD") \ MR_MACROS(PART_MOVE_TO_SHARD, "PART_MOVE_TO_SHARD") \ MR_MACROS(PLAINTEXT_PASSWORD, "PLAINTEXT_PASSWORD") \ MR_MACROS(SHA256_HASH, "SHA256_HASH") \ diff --git a/src/Storages/System/StorageSystemUsers.cpp b/src/Storages/System/StorageSystemUsers.cpp index 7589afdeb3e..8c40efc0897 100644 --- a/src/Storages/System/StorageSystemUsers.cpp +++ b/src/Storages/System/StorageSystemUsers.cpp @@ -149,6 +149,16 @@ void StorageSystemUsers::fillData(MutableColumns & res_columns, ContextPtr conte { auth_params_json.set("realm", auth_data.getKerberosRealm()); } + else if (auth_data.getType() == AuthenticationType::ONE_TIME_PASSWORD) + { + const auto & secret = auth_data.getOneTimePassword(); + if (secret.params != OneTimePasswordParams{}) + { + 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) { Poco::JSON::Array::Ptr common_names = new Poco::JSON::Array(); diff --git a/tests/fuzz/dictionaries/keywords.dict b/tests/fuzz/dictionaries/keywords.dict index a37675ebcad..e04c5618af3 100644 --- a/tests/fuzz/dictionaries/keywords.dict +++ b/tests/fuzz/dictionaries/keywords.dict @@ -341,14 +341,15 @@ "NULL" "NULLS" "OFFSET" -"ON" "ON DELETE" "ON UPDATE" "ON VOLUME" +"ON" +"ONE_TIME_PASSWORD" "ONLY" "OPTIMIZE TABLE" -"OR" "OR REPLACE" +"OR" "ORDER BY" "OUTER" "OVER" diff --git a/tests/integration/test_totp_auth/__init__.py b/tests/integration/test_totp_auth/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/test_totp_auth/config/.gitkeep b/tests/integration/test_totp_auth/config/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/test_totp_auth/test_totp.py b/tests/integration/test_totp_auth/test_totp.py new file mode 100644 index 00000000000..a9399153391 --- /dev/null +++ b/tests/integration/test_totp_auth/test_totp.py @@ -0,0 +1,150 @@ +import base64 +import hashlib +import hmac +import os +import random +import struct +import time +from fnmatch import fnmatch + +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", + user_configs=["config/users.xml"], +) + + +def create_config(totp_secret): + config = f""" + + + + + + + + + {totp_secret} + 10 + 9 + SHA256 + + + 1 + + ::/0 + + default + default + + + +""".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, 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, 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) + return f"{otp:0{digits}d}" + + +def test_one_time_password(started_cluster): + query_text = "SELECT currentUser() || toString(42)" + 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"] + ) + assert "AUTHENTICATION_FAILED" in node.query_and_get_error( + query_text, user="totuser", password=old_password + ) + + assert "totuser42\n" == node.query( + query_text, user="totuser", password=generate_totp(**totuser_secret) + ) + + assert "totuser42\n" == node.query( + query_text, user="totuser", password=generate_totp(**totuser_secret) + ) + + assert "CREATE USER totuser IDENTIFIED WITH one_time_password" in node.query( + "SHOW CREATE USER totuser", + user="totuser", + password=generate_totp(**totuser_secret), + ) + + for bad_secret, error_message in [ + ("i11egalbase32", "Invalid character in*secret"), + ("abc$d", "Invalid character in*secret"), + (" ", "Empty secret"), + (" =", "Empty secret"), + ("", "Empty secret"), + ]: + err_resp = node.query_and_get_error( + f"CREATE USER user2 IDENTIFIED WITH one_time_password BY '{bad_secret}'", + user="totuser", + password=generate_totp(**totuser_secret), + ) + 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 'inwg sy3l jbxx k43f biaa'", + user="totuser", + password=generate_totp(**totuser_secret), + ) + + assert "user242\n" == node.query( + query_text, + user="user2", + password=generate_totp("INWGSY3LJBXXK43FBIAA===="), + ) + + resp = node.query( + """ + SELECT + name, + auth_type[1], + JSONExtractString(auth_params[1], 'algorithm'), + JSONExtractString(auth_params[1], 'num_digits'), + JSONExtractString(auth_params[1], 'period') + FROM system.users WHERE name IN ('totuser', 'user2') + ORDER BY 1 + """, + user="totuser", + password=generate_totp(**totuser_secret), + ).splitlines() + assert resp[0].startswith("totuser\tone_time_password\tSHA256\t9\t10"), resp + assert resp[1].startswith("user2\tone_time_password"), resp diff --git a/tests/queries/0_stateless/02117_show_create_table_system.reference b/tests/queries/0_stateless/02117_show_create_table_system.reference index ef5a2c6665f..6cc2ea210f8 100644 --- a/tests/queries/0_stateless/02117_show_create_table_system.reference +++ b/tests/queries/0_stateless/02117_show_create_table_system.reference @@ -1150,7 +1150,7 @@ CREATE TABLE system.users `name` String, `id` UUID, `storage` String, - `auth_type` Array(Enum8('no_password' = 0, 'plaintext_password' = 1, 'sha256_password' = 2, 'double_sha1_password' = 3, 'ldap' = 4, 'kerberos' = 5, 'ssl_certificate' = 6, 'bcrypt_password' = 7, 'ssh_key' = 8, 'http' = 9, 'jwt' = 10)), + `auth_type` Array(Enum8('no_password' = 0, 'plaintext_password' = 1, 'sha256_password' = 2, 'double_sha1_password' = 3, 'one_time_password' = 4, 'ldap' = 5, 'kerberos' = 6, 'ssl_certificate' = 7, 'bcrypt_password' = 8, 'ssh_key' = 9, 'http' = 10, 'jwt' = 11)), `auth_params` Array(String), `host_ip` Array(String), `host_names` Array(String), diff --git a/tests/queries/0_stateless/02550_client_connections_credentials.reference b/tests/queries/0_stateless/02550_client_connections_credentials.reference index 1a0988002b1..b925adbed45 100644 --- a/tests/queries/0_stateless/02550_client_connections_credentials.reference +++ b/tests/queries/0_stateless/02550_client_connections_credentials.reference @@ -16,12 +16,12 @@ user MySQL: Authentication failed default password -default: Authentication failed: password is incorrect, or there is no user with such name. +default: Authentication failed: password is incorrect, or there is no user with such name default history_file Cannot create file: /no/such/dir/.history root overrides -foo: Authentication failed: password is incorrect, or there is no user with such name. +foo: Authentication failed: password is incorrect, or there is no user with such name default default -foo: Authentication failed: password is incorrect, or there is no user with such name. +foo: Authentication failed: password is incorrect, or there is no user with such name diff --git a/tests/queries/0_stateless/02550_client_connections_credentials.sh b/tests/queries/0_stateless/02550_client_connections_credentials.sh index a88f3fc7880..70e4467250b 100755 --- a/tests/queries/0_stateless/02550_client_connections_credentials.sh +++ b/tests/queries/0_stateless/02550_client_connections_credentials.sh @@ -117,7 +117,7 @@ echo 'user' $CLICKHOUSE_CLIENT --config $CONFIG --connection test_user -q 'select currentUser()' |& grep -F -o 'MySQL: Authentication failed' $CLICKHOUSE_CLIENT --config $CONFIG --connection test_user --user default -q 'select currentUser()' echo 'password' -$CLICKHOUSE_CLIENT --config $CONFIG --connection test_password -q 'select currentUser()' |& grep -F -o 'default: Authentication failed: password is incorrect, or there is no user with such name.' +$CLICKHOUSE_CLIENT --config $CONFIG --connection test_password -q 'select currentUser()' |& grep -F -o 'default: Authentication failed: password is incorrect, or there is no user with such name' $CLICKHOUSE_CLIENT --config $CONFIG --connection test_password --password "" -q 'select currentUser()' echo 'history_file' $CLICKHOUSE_CLIENT --progress off --interactive --config $CONFIG --connection test_history_file -q 'select 1'