mirror of
https://github.com/ClickHouse/ClickHouse.git
synced 2024-09-19 16:20:50 +00:00
Merge bfb0b32de8
into f4038e3153
This commit is contained in:
commit
92e6631c31
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -369,3 +369,6 @@
|
||||
[submodule "contrib/numactl"]
|
||||
path = contrib/numactl
|
||||
url = https://github.com/ClickHouse/numactl.git
|
||||
[submodule "contrib/jwt-cpp"]
|
||||
path = contrib/jwt-cpp
|
||||
url = https://github.com/Thalhammer/jwt-cpp.git
|
||||
|
2
contrib/CMakeLists.txt
vendored
2
contrib/CMakeLists.txt
vendored
@ -80,7 +80,7 @@ add_contrib (openldap-cmake openldap)
|
||||
add_contrib (grpc-cmake grpc)
|
||||
add_contrib (msgpack-c-cmake msgpack-c)
|
||||
add_contrib (libarchive-cmake libarchive)
|
||||
|
||||
add_contrib (jwt-cpp-cmake jwt-cpp)
|
||||
add_contrib (corrosion-cmake corrosion)
|
||||
|
||||
if (ENABLE_FUZZING)
|
||||
|
1
contrib/jwt-cpp
vendored
Submodule
1
contrib/jwt-cpp
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit a6927cb8140858c34e05d1a954626b9849fbcdfc
|
3
contrib/jwt-cpp-cmake/CMakeLists.txt
Normal file
3
contrib/jwt-cpp-cmake/CMakeLists.txt
Normal file
@ -0,0 +1,3 @@
|
||||
add_library(_jwt-cpp INTERFACE)
|
||||
target_include_directories(_jwt-cpp SYSTEM BEFORE INTERFACE "${ClickHouse_SOURCE_DIR}/contrib/jwt-cpp/include/")
|
||||
add_library(ch_contrib::jwt-cpp ALIAS _jwt-cpp)
|
@ -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)
|
||||
- HTTP [Authenticator](./http.md)
|
||||
- JWT [Authenticator](./jwt.md)
|
||||
|
205
docs/en/operations/external-authenticators/jwt.md
Normal file
205
docs/en/operations/external-authenticators/jwt.md
Normal file
@ -0,0 +1,205 @@
|
||||
---
|
||||
slug: /en/operations/external-authenticators/jwt
|
||||
---
|
||||
# JWT
|
||||
import SelfManaged from '@site/docs/en/_snippets/_self_managed_only_no_roadmap.md';
|
||||
|
||||
<SelfManaged />
|
||||
|
||||
Existing and properly configured ClickHouse users can be authenticated via JWT.
|
||||
|
||||
Currently, JWT can only be used as an external authenticator for existing users, which are defined in `users.xml` or in local access control paths.
|
||||
JWT shall contain the name of the ClickHouse user under "sub" claim.
|
||||
The username will be extracted from the JWT after validating the token expiration and against the signature. Signature can be validated by:
|
||||
- static public key
|
||||
- static JWKS
|
||||
- received from the JWKS servers
|
||||
Additionally, each user can be verified using a JWT payload. In this case, after running general checks, the occurrence of CLAIMS from the user settings in the JWT payload is checked.
|
||||
|
||||
For this approach, JWT validators must be configured in the system and must be enabled in ClickHouse config.
|
||||
|
||||
|
||||
## Enabling JWT validators in ClickHouse {#enabling-jwt-validators-in-clickhouse}
|
||||
|
||||
To enable JWT validators, one should include `jwt_verifiers` section in `config.xml`. This section may contain several JWT verifiers, minimum is 1.
|
||||
|
||||
### Verifying JWT signature using static key {$verifying-jwt-signature-using-static-key}
|
||||
|
||||
**Example**
|
||||
```xml
|
||||
<clickhouse>
|
||||
<!- ... -->
|
||||
<jwt_verifiers>
|
||||
<basic_jwt_validator>
|
||||
<algo>HS256</algo>
|
||||
<single_key>c3VwZXIKa2V5</single_key>
|
||||
<single_key_in_base64>true</single_key_in_base64>
|
||||
</basic_jwt_validator>
|
||||
</jwt_verifiers>
|
||||
</clickhouse>
|
||||
```
|
||||
|
||||
#### Parameters:
|
||||
|
||||
- `algo` - Algorithm for validate signature. Supported:
|
||||
|
||||
| HMSC | RSA | ECDSA | PSS | EdDSA |
|
||||
| ----- | ----- | ------ | ----- | ------- |
|
||||
| HS256 | RS256 | ES256 | PS256 | Ed25519 |
|
||||
| HS384 | RS384 | ES384 | PS384 | Ed448 |
|
||||
| HS512 | RS512 | ES512 | PS512 | |
|
||||
| | | ES256K | | |
|
||||
Also support None.
|
||||
- `single_key` - key for HS* algorithms. Required in these algorithms.
|
||||
- `single_key_in_base64` - a sign that the `single_key` key is encoded in base64
|
||||
- `public_key` - public key for validate in all algorithms except HS* family and None. Required in these algorithms.
|
||||
- `private_key` - private key for validate in all algorithms except HS* family and None. Optional in these algorithms.
|
||||
- `public_key_password` - public key password for verification in all algorithms except the HS* family and None. Optional in these algorithms.
|
||||
- `private_key_password` - private key password for verification in all algorithms except the HS* family and None. Optional in these algorithms.
|
||||
|
||||
### Verifying JWT signature using static JWKS {$verifying-jwt-signature-using-static-jwks}
|
||||
|
||||
**Example**
|
||||
```xml
|
||||
<clickhouse>
|
||||
<!- ... -->
|
||||
<jwt_verifiers>
|
||||
<basic_jwt_validator>
|
||||
<static_jwks>CONTENT_OF_JWKS</static_jwks>
|
||||
</basic_jwt_validator>
|
||||
</jwt_verifiers>
|
||||
</clickhouse>
|
||||
```
|
||||
|
||||
#### Parameters:
|
||||
- `static_jwks` - content of JWKS in json
|
||||
- `static_jwks_file` - path to file with JWKS
|
||||
|
||||
:::note
|
||||
Only RS* family algorithms are supported
|
||||
:::
|
||||
|
||||
:::note
|
||||
Only one of `static_jwks` or `static_jwks_file` keys must be present in one verifier
|
||||
:::
|
||||
|
||||
### Verifying JWT signature using JWKS servers {$verifying-jwt-signature-using-static-jwks}
|
||||
|
||||
**Example**
|
||||
```xml
|
||||
<clickhouse>
|
||||
<!- ... -->
|
||||
<jwt_verifiers>
|
||||
<basic_auth_server>
|
||||
<uri>http://localhost:8000/jwks.json</uri>
|
||||
<connection_timeout_ms>1000</connection_timeout_ms>
|
||||
<receive_timeout_ms>1000</receive_timeout_ms>
|
||||
<send_timeout_ms>1000</send_timeout_ms>
|
||||
<max_tries>3</max_tries>
|
||||
<retry_initial_backoff_ms>50</retry_initial_backoff_ms>
|
||||
<retry_max_backoff_ms>1000</retry_max_backoff_ms>
|
||||
<refresh_ms>300000</refresh_ms>
|
||||
</basic_auth_server>
|
||||
</jwt_verifiers>
|
||||
</clickhouse>
|
||||
```
|
||||
|
||||
#### Parameters:
|
||||
|
||||
- `uri` - URI for making authentication request
|
||||
- `refresh_ms` - Period for resend request for refreshing JWKS. Default: 300000 ms.
|
||||
|
||||
Timeouts in milliseconds on the socket used for communicating with the server:
|
||||
- `connection_timeout_ms` - Default: 1000 ms.
|
||||
- `receive_timeout_ms` - Default: 1000 ms.
|
||||
- `send_timeout_ms` - Default: 1000 ms.
|
||||
|
||||
Retry parameters:
|
||||
- `max_tries` - The maximum number of attempts to make an authentication request. Default: 3
|
||||
- `retry_initial_backoff_ms` - The backoff initial interval on retry. Default: 50 ms
|
||||
- `retry_max_backoff_ms` - The maximum backoff interval. Default: 1000 ms
|
||||
|
||||
Note, that you can define multiple HTTP servers inside the `jwt_verifiers` section using distinct names.
|
||||
|
||||
### Enabling JWT authentication in `users.xml` {#enabling-jwt-auth-in-users-xml}
|
||||
|
||||
In order to enable HTTP authentication for the user, specify `jwt` section instead of `password` or similar sections in the user definition.
|
||||
|
||||
Parameters:
|
||||
- `claims` - An optional string containing a json object that should be contained in the token payload.
|
||||
|
||||
Example (goes into `users.xml`):
|
||||
```xml
|
||||
<clickhouse>
|
||||
<!- ... -->
|
||||
<my_user>
|
||||
<!- ... -->
|
||||
<jwt>
|
||||
<claims>{"resource_access":{"account": {"roles": ["view-profile"]}}}</claims>
|
||||
</jwt>
|
||||
</my_user>
|
||||
</clickhouse>
|
||||
```
|
||||
|
||||
In this sample payload must contains "view-profile" in array on path resource_access.account.roles like
|
||||
|
||||
```
|
||||
{
|
||||
...
|
||||
"realm_access": {
|
||||
"roles": [
|
||||
"default-roles-master",
|
||||
"offline_access",
|
||||
"uma_authorization"
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
:::note
|
||||
Note that JWT authentication cannot be used alongside with any other authentication mechanism. The presence of any other sections like `password` alongside `jwt` will force ClickHouse to shutdown.
|
||||
:::
|
||||
|
||||
### Enabling JWT authentication using SQL {#enabling-jwt-auth-using-sql}
|
||||
|
||||
When [SQL-driven Access Control and Account Management](/docs/en/guides/sre/user-management/index.md#access-control) is enabled in ClickHouse, users identified by JWT authentication can also be created using SQL statements.
|
||||
|
||||
```sql
|
||||
CREATE USER my_user IDENTIFIED WITH jwt CLAIMS '{"resource_access":{"account": {"roles": ["view-profile"]}}}'
|
||||
```
|
||||
|
||||
...or, without additional JWT payload checks
|
||||
|
||||
```sql
|
||||
CREATE USER my_user IDENTIFIED WITH jwt
|
||||
```
|
||||
|
||||
## JWT authorization examples {#jwt-authorization-examples}
|
||||
|
||||
#### Console client
|
||||
|
||||
```
|
||||
clickhouse-client -jwt <token>
|
||||
```
|
||||
|
||||
#### HTTP requests
|
||||
|
||||
```
|
||||
curl 'http://localhost:8080/?add_http_cors_header=1&default_format=JSONCompact&max_result_rows=1000&max_result_bytes=10000000&result_overflow_mode=break' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content type: text/plain;charset=UTF-8' \
|
||||
--data-raw 'SELECT current_user()'
|
||||
```
|
||||
:::note
|
||||
The token can be obtained (by priority):
|
||||
- header X-ClickHouse-JWT-Token
|
||||
- header Authorization
|
||||
- request parameter "token". In this case, the "Bearer" prefix should not exist.
|
||||
:::
|
||||
|
||||
### Passing session settings {#passing-session-settings}
|
||||
|
||||
If `settings_key` exists in the `jwt_verifiers` section or exists in the verifier section and the payload contains a sub-object of that `settings_key`, ClickHouse will attempt to parse its key:value pairs as string values and set them as session settings for the currently authenticated user. If parsing fails, the JWT payload will be ignored.
|
||||
|
||||
The `settings_key` in the verifier section takes precedence over the `settings_key` from the `jwt_verifiers` section. If `settings_key` in the verifier section does not exist, the `settings_key` from the `jwt_verifiers` section will be used.
|
@ -598,7 +598,7 @@ AuthResult AccessControl::authenticate(const Credentials & credentials, const Po
|
||||
try
|
||||
{
|
||||
const auto auth_result = MultipleAccessStorage::authenticate(credentials, address, *external_authenticators, allow_no_password,
|
||||
allow_plaintext_password);
|
||||
allow_plaintext_password, isJWTAllowed());
|
||||
if (authentication_quota)
|
||||
authentication_quota->reset(QuotaType::FAILED_SEQUENTIAL_AUTHENTICATIONS);
|
||||
|
||||
@ -689,6 +689,11 @@ bool AccessControl::isNoPasswordAllowed() const
|
||||
return allow_no_password;
|
||||
}
|
||||
|
||||
bool AccessControl::isJWTAllowed() const
|
||||
{
|
||||
return external_authenticators->isJWTAllowed();
|
||||
}
|
||||
|
||||
void AccessControl::setPlaintextPasswordAllowed(bool allow_plaintext_password_)
|
||||
{
|
||||
allow_plaintext_password = allow_plaintext_password_;
|
||||
|
@ -148,6 +148,8 @@ public:
|
||||
void setNoPasswordAllowed(bool allow_no_password_);
|
||||
bool isNoPasswordAllowed() const;
|
||||
|
||||
bool isJWTAllowed() const;
|
||||
|
||||
/// Allows users with plaintext password (by default it's allowed).
|
||||
void setPlaintextPasswordAllowed(bool allow_plaintext_password_);
|
||||
bool isPlaintextPasswordAllowed() const;
|
||||
|
@ -110,7 +110,7 @@ bool Authentication::areCredentialsValid(
|
||||
throw Authentication::Require<BasicCredentials>("ClickHouse Basic Authentication");
|
||||
|
||||
case AuthenticationType::JWT:
|
||||
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud");
|
||||
throw Authentication::Require<JWTCredentials>("ClickHouse JWT Authentication");
|
||||
|
||||
case AuthenticationType::KERBEROS:
|
||||
return external_authenticators.checkKerberosCredentials(auth_data.getKerberosRealm(), *gss_acceptor_context);
|
||||
@ -154,7 +154,7 @@ bool Authentication::areCredentialsValid(
|
||||
throw Authentication::Require<BasicCredentials>("ClickHouse X.509 Authentication");
|
||||
|
||||
case AuthenticationType::JWT:
|
||||
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud");
|
||||
throw Authentication::Require<JWTCredentials>("ClickHouse JWT Authentication");
|
||||
|
||||
case AuthenticationType::SSH_KEY:
|
||||
#if USE_SSH
|
||||
@ -201,7 +201,7 @@ bool Authentication::areCredentialsValid(
|
||||
#endif
|
||||
|
||||
case AuthenticationType::JWT:
|
||||
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud");
|
||||
throw Authentication::Require<JWTCredentials>("ClickHouse JWT Authentication");
|
||||
|
||||
case AuthenticationType::BCRYPT_PASSWORD:
|
||||
return checkPasswordBcrypt(basic_credentials->getPassword(), auth_data.getPasswordHashBinary());
|
||||
@ -292,6 +292,38 @@ bool Authentication::areCredentialsValid(
|
||||
}
|
||||
#endif
|
||||
|
||||
if (const auto * jwt_credentials = typeid_cast<const JWTCredentials *>(&credentials))
|
||||
{
|
||||
switch (auth_data.getType())
|
||||
{
|
||||
case AuthenticationType::NO_PASSWORD:
|
||||
case AuthenticationType::PLAINTEXT_PASSWORD:
|
||||
case AuthenticationType::SHA256_PASSWORD:
|
||||
case AuthenticationType::DOUBLE_SHA1_PASSWORD:
|
||||
case AuthenticationType::BCRYPT_PASSWORD:
|
||||
case AuthenticationType::LDAP:
|
||||
case AuthenticationType::HTTP:
|
||||
case AuthenticationType::KERBEROS:
|
||||
throw Authentication::Require<BasicCredentials>("ClickHouse Basic Authentication");
|
||||
|
||||
case AuthenticationType::JWT:
|
||||
return external_authenticators.checkJWTCredentials(auth_data.getJWTClaims(), *jwt_credentials, settings);
|
||||
|
||||
case AuthenticationType::SSL_CERTIFICATE:
|
||||
throw Authentication::Require<BasicCredentials>("ClickHouse X.509 Authentication");
|
||||
|
||||
case AuthenticationType::SSH_KEY:
|
||||
#if USE_SSH
|
||||
throw Authentication::Require<SshCredentials>("SSH Keys Authentication");
|
||||
#else
|
||||
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "SSH is disabled, because ClickHouse is built without libssh");
|
||||
#endif
|
||||
|
||||
case AuthenticationType::MAX:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ([[maybe_unused]] const auto * always_allow_credentials = typeid_cast<const AlwaysAllowCredentials *>(&credentials))
|
||||
return true;
|
||||
|
||||
|
@ -14,7 +14,9 @@
|
||||
#include <base/hex.h>
|
||||
#include <boost/algorithm/hex.hpp>
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
#include <picojson/picojson.h>
|
||||
|
||||
#include "Access/Common/AuthenticationType.h"
|
||||
#include <Access/Common/SSLCertificateSubjects.h>
|
||||
#include "config.h"
|
||||
|
||||
@ -332,7 +334,10 @@ std::shared_ptr<ASTAuthenticationData> AuthenticationData::toAST() const
|
||||
}
|
||||
case AuthenticationType::JWT:
|
||||
{
|
||||
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is available only in ClickHouse Cloud");
|
||||
const auto &claims = getJWTClaims();
|
||||
if (!claims.empty())
|
||||
node->children.push_back(std::make_shared<ASTLiteral>(claims));
|
||||
break;
|
||||
}
|
||||
case AuthenticationType::KERBEROS:
|
||||
{
|
||||
@ -541,6 +546,20 @@ AuthenticationData AuthenticationData::fromAST(const ASTAuthenticationData & que
|
||||
auth_data.setHTTPAuthenticationServerName(server);
|
||||
auth_data.setHTTPAuthenticationScheme(scheme);
|
||||
}
|
||||
else if (query.type == AuthenticationType::JWT)
|
||||
{
|
||||
if (!args.empty())
|
||||
{
|
||||
String value = checkAndGetLiteralArgument<String>(args[0], "claims");
|
||||
picojson::value json_obj;
|
||||
auto error = picojson::parse(json_obj, value);
|
||||
if (!error.empty())
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Bad JWT claims: {}", error);
|
||||
if (!json_obj.is<picojson::object>())
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Bad JWT claims: is not an object");
|
||||
auth_data.setJWTClaims(value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw Exception(ErrorCodes::LOGICAL_ERROR, "Unexpected ASTAuthenticationData structure");
|
||||
|
@ -74,6 +74,9 @@ public:
|
||||
const String & getHTTPAuthenticationServerName() const { return http_auth_server_name; }
|
||||
void setHTTPAuthenticationServerName(const String & name) { http_auth_server_name = name; }
|
||||
|
||||
const String & getJWTClaims() const { return jwt_claims; }
|
||||
void setJWTClaims(const String &jwt_claims_) { jwt_claims = jwt_claims_; }
|
||||
|
||||
friend bool operator ==(const AuthenticationData & lhs, const AuthenticationData & rhs);
|
||||
friend bool operator !=(const AuthenticationData & lhs, const AuthenticationData & rhs) { return !(lhs == rhs); }
|
||||
|
||||
@ -106,6 +109,7 @@ private:
|
||||
/// HTTP authentication properties
|
||||
String http_auth_server_name;
|
||||
HTTPAuthenticationScheme http_auth_scheme = HTTPAuthenticationScheme::BASIC;
|
||||
String jwt_claims;
|
||||
};
|
||||
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
#include <Access/Credentials.h>
|
||||
#include <Access/Common/SSLCertificateSubjects.h>
|
||||
#include <Common/Exception.h>
|
||||
#include <Common/logger_useful.h>
|
||||
|
||||
#include <jwt-cpp/jwt.h>
|
||||
|
||||
namespace DB
|
||||
{
|
||||
@ -97,4 +100,26 @@ const String & BasicCredentials::getPassword() const
|
||||
return password;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
String extractSubjectFromToken(const String& token)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto decoded_jwt = jwt::decode(token);
|
||||
return decoded_jwt.get_subject();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
throw Exception(ErrorCodes::LOGICAL_ERROR, "Failed to validate jwt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JWTCredentials::JWTCredentials(const String& token_)
|
||||
: Credentials(extractSubjectFromToken(token_))
|
||||
, token(token_)
|
||||
{
|
||||
is_ready = !user_name.empty();
|
||||
}
|
||||
}
|
||||
|
@ -123,4 +123,20 @@ private:
|
||||
};
|
||||
#endif
|
||||
|
||||
class JWTCredentials: public Credentials
|
||||
{
|
||||
public:
|
||||
explicit JWTCredentials(const String& token_);
|
||||
const String & getToken() const
|
||||
{
|
||||
if (!isReady())
|
||||
{
|
||||
throwNotReady();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
private:
|
||||
String token;
|
||||
};
|
||||
|
||||
}
|
||||
|
@ -2,14 +2,21 @@
|
||||
#include <Access/LDAPClient.h>
|
||||
#include <Access/SettingsAuthResponseParser.h>
|
||||
#include <Access/resolveSetting.h>
|
||||
#include "Common/Logger.h"
|
||||
#include "Common/logger_useful.h"
|
||||
#include <Common/Exception.h>
|
||||
#include <Common/SettingsChanges.h>
|
||||
#include <Common/SipHash.h>
|
||||
#include <Common/quoteString.h>
|
||||
#include "Access/AccessControl.h"
|
||||
#include "Access/Credentials.h"
|
||||
#include "Access/JWTVerifier.h"
|
||||
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
#include <Poco/Util/AbstractConfiguration.h>
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
@ -254,6 +261,68 @@ HTTPAuthClientParams parseHTTPAuthParams(const Poco::Util::AbstractConfiguration
|
||||
return http_auth_params;
|
||||
}
|
||||
|
||||
std::unique_ptr<DB::IJWTVerifier> makeJWTVerifier(const Poco::Util::AbstractConfiguration & config, const String & prefix, const String &name, const String &global_settings_key)
|
||||
{
|
||||
auto settings_key = String(global_settings_key);
|
||||
if (config.hasProperty(prefix + ".settings_key"))
|
||||
settings_key = config.getString(prefix + ".settings_key");
|
||||
|
||||
if (config.hasProperty(prefix + ".algo"))
|
||||
{
|
||||
SimpleJWTVerifierParams params = {};
|
||||
params.settings_key = settings_key;
|
||||
params.algo = config.getString(prefix + ".algo");
|
||||
params.single_key = config.getString(prefix + ".single_key", "");
|
||||
params.single_key_in_base64 = config.getBool(prefix + ".single_key_in_base64", false);
|
||||
params.public_key = config.getString(prefix + ".public_key", "");
|
||||
params.private_key = config.getString(prefix + ".private_key", "");
|
||||
params.public_key_password = config.getString(prefix + ".public_key_password", "");
|
||||
params.private_key_password = config.getString(prefix + ".private_key_password", "");
|
||||
params.validate();
|
||||
auto result = std::make_unique<SimpleJWTVerifier>(name);
|
||||
result->init(params);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::shared_ptr<IJWKSProvider> provider;
|
||||
if (config.hasProperty(prefix + ".uri"))
|
||||
{
|
||||
JWKSAuthClientParams params;
|
||||
|
||||
params.uri = config.getString(prefix + ".uri");
|
||||
|
||||
size_t connection_timeout_ms = config.getInt(prefix + ".connection_timeout_ms", 1000);
|
||||
size_t receive_timeout_ms = config.getInt(prefix + ".receive_timeout_ms", 1000);
|
||||
size_t send_timeout_ms = config.getInt(prefix + ".send_timeout_ms", 1000);
|
||||
params.timeouts = ConnectionTimeouts()
|
||||
.withConnectionTimeout(Poco::Timespan(connection_timeout_ms * 1000))
|
||||
.withReceiveTimeout(Poco::Timespan(receive_timeout_ms * 1000))
|
||||
.withSendTimeout(Poco::Timespan(send_timeout_ms * 1000));
|
||||
|
||||
params.max_tries = config.getInt(prefix + ".max_tries", 3);
|
||||
params.retry_initial_backoff_ms = config.getInt(prefix + ".retry_initial_backoff_ms", 50);
|
||||
params.retry_max_backoff_ms = config.getInt(prefix + ".retry_max_backoff_ms", 1000);
|
||||
params.refresh_ms = config.getInt(prefix + ".refrest_ms", 300000);
|
||||
provider = std::make_shared<JWKSClient>(params);
|
||||
}
|
||||
else if (config.hasProperty(prefix + ".static_jwks") || config.hasProperty(prefix + ".static_jwks_file"))
|
||||
{
|
||||
StaticJWKSParams params;
|
||||
params.static_jwks = config.getString(prefix + ".static_jwks", "");
|
||||
params.static_jwks_file = config.getString(prefix + ".static_jwks_file", "");
|
||||
params.validate();
|
||||
auto instance = std::make_shared<StaticJWKS>();
|
||||
instance->init(params);
|
||||
provider = instance;
|
||||
}
|
||||
else
|
||||
throw DB::Exception(ErrorCodes::BAD_ARGUMENTS, "unsupported configuration");
|
||||
auto result = std::make_unique<JWKSVerifier>(name, provider);
|
||||
JWTVerifierParams params = {.settings_key = settings_key};
|
||||
result->init(params);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void parseLDAPRoleSearchParams(LDAPClient::RoleSearchParams & params, const Poco::Util::AbstractConfiguration & config, const String & prefix)
|
||||
@ -271,6 +340,13 @@ void ExternalAuthenticators::resetImpl()
|
||||
ldap_client_params_blueprint.clear();
|
||||
ldap_caches.clear();
|
||||
kerberos_params.reset();
|
||||
jwt_verifiers.clear();
|
||||
}
|
||||
|
||||
bool ExternalAuthenticators::isJWTAllowed() const
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
return !jwt_verifiers.empty();
|
||||
}
|
||||
|
||||
void ExternalAuthenticators::reset()
|
||||
@ -290,8 +366,10 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
|
||||
std::size_t ldap_servers_key_count = 0;
|
||||
std::size_t kerberos_keys_count = 0;
|
||||
std::size_t http_auth_server_keys_count = 0;
|
||||
std::size_t jwt_verifiers_count = 0;
|
||||
|
||||
const String http_auth_servers_config = "http_authentication_servers";
|
||||
const String jwt_verifiers_config = "jwt_verifiers";
|
||||
|
||||
for (auto key : all_keys)
|
||||
{
|
||||
@ -304,6 +382,7 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
|
||||
ldap_servers_key_count += (key == "ldap_servers");
|
||||
kerberos_keys_count += (key == "kerberos");
|
||||
http_auth_server_keys_count += (key == http_auth_servers_config);
|
||||
jwt_verifiers_count += (key == jwt_verifiers_config);
|
||||
}
|
||||
|
||||
if (ldap_servers_key_count > 1)
|
||||
@ -315,6 +394,9 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
|
||||
if (http_auth_server_keys_count > 1)
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple http_authentication_servers sections are not allowed");
|
||||
|
||||
if (jwt_verifiers_count > 1)
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Multiple jwt_verifiers sections are not allowed");
|
||||
|
||||
Poco::Util::AbstractConfiguration::Keys http_auth_server_names;
|
||||
config.keys(http_auth_servers_config, http_auth_server_names);
|
||||
http_auth_servers.clear();
|
||||
@ -369,6 +451,26 @@ void ExternalAuthenticators::setConfiguration(const Poco::Util::AbstractConfigur
|
||||
{
|
||||
tryLogCurrentException(log, "Could not parse Kerberos section");
|
||||
}
|
||||
|
||||
Poco::Util::AbstractConfiguration::Keys jwt_verifiers_keys;
|
||||
config.keys(jwt_verifiers_config, jwt_verifiers_keys);
|
||||
jwt_verifiers.clear();
|
||||
String jwt_verifier_settings_key;
|
||||
if (config.has(jwt_verifiers_config + ".settings_key"))
|
||||
jwt_verifier_settings_key = config.getString(jwt_verifiers_config + ".settings_key");
|
||||
for (const auto & jwt_verifier : jwt_verifiers_keys)
|
||||
{
|
||||
if (jwt_verifier == "settings_key") continue;
|
||||
String prefix = fmt::format("{}.{}", jwt_verifiers_config, jwt_verifier);
|
||||
try
|
||||
{
|
||||
jwt_verifiers[jwt_verifier] = makeJWTVerifier(config, prefix, jwt_verifier, jwt_verifier_settings_key);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
tryLogCurrentException(log, "Could not parse JWT verifier" + backQuote(jwt_verifier));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UInt128 computeParamsHash(const LDAPClient::Params & params, const LDAPClient::RoleSearchParamsList * role_search_params)
|
||||
@ -547,6 +649,24 @@ HTTPAuthClientParams ExternalAuthenticators::getHTTPAuthenticationParams(const S
|
||||
return it->second;
|
||||
}
|
||||
|
||||
bool ExternalAuthenticators::checkJWTCredentials(const String &claims, const JWTCredentials & credentials, SettingsChanges &settings) const
|
||||
{
|
||||
std::lock_guard lock{mutex};
|
||||
|
||||
const auto token = String(credentials.getToken());
|
||||
const auto &user_name = credentials.getUserName();
|
||||
for (const auto &it : jwt_verifiers)
|
||||
{
|
||||
if (it.second->verify(claims, token, settings))
|
||||
{
|
||||
LOG_DEBUG(getLogger("JWTAuth"), "success auth with JWT for {} by {}", user_name, it.first);
|
||||
return true;
|
||||
}
|
||||
LOG_TRACE(getLogger("JWTAuth"), "failed auth with JWT for {} by {}", user_name, it.first);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ExternalAuthenticators::checkHTTPBasicCredentials(
|
||||
const String & server, const BasicCredentials & credentials, SettingsChanges & settings) const
|
||||
{
|
||||
|
@ -4,6 +4,7 @@
|
||||
#include <Access/GSSAcceptor.h>
|
||||
#include <Access/HTTPAuthClient.h>
|
||||
#include <Access/LDAPClient.h>
|
||||
#include <Access/JWTVerifier.h>
|
||||
#include <base/defines.h>
|
||||
#include <base/extended_types.h>
|
||||
#include <base/types.h>
|
||||
@ -12,6 +13,7 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
@ -31,6 +33,7 @@ namespace DB
|
||||
{
|
||||
|
||||
class SettingsChanges;
|
||||
class AccessControl;
|
||||
|
||||
class ExternalAuthenticators
|
||||
{
|
||||
@ -43,9 +46,12 @@ public:
|
||||
const LDAPClient::RoleSearchParamsList * role_search_params = nullptr, LDAPClient::SearchResultsList * role_search_results = nullptr) const;
|
||||
bool checkKerberosCredentials(const String & realm, const GSSAcceptorContext & credentials) const;
|
||||
bool checkHTTPBasicCredentials(const String & server, const BasicCredentials & credentials, SettingsChanges & settings) const;
|
||||
bool checkJWTCredentials(const String &claims, const JWTCredentials & credentials, SettingsChanges & settings) const;
|
||||
|
||||
GSSAcceptorContext::Params getKerberosParams() const;
|
||||
|
||||
bool isJWTAllowed() const;
|
||||
|
||||
private:
|
||||
HTTPAuthClientParams getHTTPAuthenticationParams(const String& server) const;
|
||||
|
||||
@ -65,6 +71,7 @@ private:
|
||||
mutable LDAPCaches ldap_caches TSA_GUARDED_BY(mutex) ;
|
||||
std::optional<GSSAcceptorContext::Params> kerberos_params TSA_GUARDED_BY(mutex) ;
|
||||
std::unordered_map<String, HTTPAuthClientParams> http_auth_servers TSA_GUARDED_BY(mutex) ;
|
||||
std::unordered_map<String, std::unique_ptr<IJWTVerifier>> jwt_verifiers TSA_GUARDED_BY(mutex) ;
|
||||
|
||||
void resetImpl() TSA_REQUIRES(mutex);
|
||||
};
|
||||
|
@ -7,6 +7,7 @@
|
||||
#include <Common/Exception.h>
|
||||
#include <Common/quoteString.h>
|
||||
#include <Common/callOnce.h>
|
||||
#include "Access/Common/AuthenticationType.h"
|
||||
#include <IO/WriteHelpers.h>
|
||||
#include <Interpreters/Context.h>
|
||||
#include <Poco/UUIDGenerator.h>
|
||||
@ -491,9 +492,10 @@ AuthResult IAccessStorage::authenticate(
|
||||
const Poco::Net::IPAddress & address,
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password) const
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt) const
|
||||
{
|
||||
return *authenticateImpl(credentials, address, external_authenticators, /* throw_if_user_not_exists = */ true, allow_no_password, allow_plaintext_password);
|
||||
return *authenticateImpl(credentials, address, external_authenticators, /* throw_if_user_not_exists = */ true, allow_no_password, allow_plaintext_password, allow_jwt);
|
||||
}
|
||||
|
||||
|
||||
@ -503,9 +505,10 @@ std::optional<AuthResult> IAccessStorage::authenticate(
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool throw_if_user_not_exists,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password) const
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt) const
|
||||
{
|
||||
return authenticateImpl(credentials, address, external_authenticators, throw_if_user_not_exists, allow_no_password, allow_plaintext_password);
|
||||
return authenticateImpl(credentials, address, external_authenticators, throw_if_user_not_exists, allow_no_password, allow_plaintext_password, allow_jwt);
|
||||
}
|
||||
|
||||
|
||||
@ -515,7 +518,8 @@ std::optional<AuthResult> IAccessStorage::authenticateImpl(
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool throw_if_user_not_exists,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password) const
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt) const
|
||||
{
|
||||
if (auto id = find<User>(credentials.getUserName()))
|
||||
{
|
||||
@ -527,7 +531,8 @@ std::optional<AuthResult> IAccessStorage::authenticateImpl(
|
||||
|
||||
auto auth_type = user->auth_data.getType();
|
||||
if (((auth_type == AuthenticationType::NO_PASSWORD) && !allow_no_password) ||
|
||||
((auth_type == AuthenticationType::PLAINTEXT_PASSWORD) && !allow_plaintext_password))
|
||||
((auth_type == AuthenticationType::PLAINTEXT_PASSWORD) && !allow_plaintext_password) ||
|
||||
((auth_type == AuthenticationType::JWT) && !(allow_jwt && user->auth_data.getType() == AuthenticationType::JWT)))
|
||||
throwAuthenticationTypeNotAllowed(auth_type);
|
||||
|
||||
if (!areCredentialsValid(*user, credentials, external_authenticators, auth_result.settings))
|
||||
|
@ -194,14 +194,16 @@ public:
|
||||
const Poco::Net::IPAddress & address,
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password) const;
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt) const;
|
||||
std::optional<AuthResult> authenticate(
|
||||
const Credentials & credentials,
|
||||
const Poco::Net::IPAddress & address,
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool throw_if_user_not_exists,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password) const;
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt) const;
|
||||
|
||||
/// Returns true if this storage can be stored to or restored from a backup.
|
||||
virtual bool isBackupAllowed() const { return false; }
|
||||
@ -225,7 +227,8 @@ protected:
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool throw_if_user_not_exists,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password) const;
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt) const;
|
||||
virtual bool areCredentialsValid(
|
||||
const User & user,
|
||||
const Credentials & credentials,
|
||||
|
494
src/Access/JWTVerifier.cpp
Normal file
494
src/Access/JWTVerifier.cpp
Normal file
@ -0,0 +1,494 @@
|
||||
#include "JWTVerifier.h"
|
||||
|
||||
#include <exception>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
#include <absl/strings/match.h>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <jwt-cpp/traits/kazuho-picojson/traits.h>
|
||||
#include <picojson/picojson.h>
|
||||
#include "Poco/StreamCopier.h"
|
||||
#include <Poco/String.h>
|
||||
|
||||
#include "Common/Base64.h"
|
||||
#include "Common/Exception.h"
|
||||
#include "Common/logger_useful.h"
|
||||
#include <Common/SettingsChanges.h>
|
||||
|
||||
namespace DB
|
||||
{
|
||||
|
||||
namespace ErrorCodes
|
||||
{
|
||||
extern const int JWT_ERROR;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
bool check_claims(const picojson::value &claims, const picojson::value &payload, const String &path);
|
||||
bool check_claims(const picojson::value::object &claims, const picojson::value::object &payload, const String &path)
|
||||
{
|
||||
for (const auto &it : claims)
|
||||
{
|
||||
const auto &payload_it = payload.find(it.first);
|
||||
if (payload_it == payload.end())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "Key '{}.{}' not found in JWT payload", path, it.first);
|
||||
return false;
|
||||
}
|
||||
if (!check_claims(it.second, payload_it->second, path + "." + it.first))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool check_claims(const picojson::value::array &claims, const picojson::value::array &payload, const String &path)
|
||||
{
|
||||
if (claims.size() > payload.size())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload too small for claims key '{}'", path);
|
||||
return false;
|
||||
}
|
||||
for (size_t claims_i = 0; claims_i < claims.size(); ++claims_i)
|
||||
{
|
||||
bool found = false;
|
||||
const auto &claims_val = claims.at(claims_i);
|
||||
for (const auto &payload_val : payload)
|
||||
{
|
||||
if (!check_claims(claims_val, payload_val, path + "[" + std::to_string(claims_i) + "]"))
|
||||
continue;
|
||||
found = true;
|
||||
}
|
||||
if (!found)
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not contain an object matching claims key '{}[{}]'", path, claims_i);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool check_claims(const picojson::value &claims, const picojson::value &payload, const String &path)
|
||||
{
|
||||
if (claims.is<picojson::array>())
|
||||
{
|
||||
if (!payload.is<picojson::array>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match key type 'array' in claims '{}'", path);
|
||||
return false;
|
||||
}
|
||||
return check_claims(claims.get<picojson::array>(), payload.get<picojson::array>(), path);
|
||||
}
|
||||
if (claims.is<picojson::object>())
|
||||
{
|
||||
if (!payload.is<picojson::object>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match key type 'object' in claims '{}'", path);
|
||||
return false;
|
||||
}
|
||||
return check_claims(claims.get<picojson::object>(), payload.get<picojson::object>(), path);
|
||||
}
|
||||
if (claims.is<bool>())
|
||||
{
|
||||
if (!payload.is<bool>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match key type 'bool' in claims '{}'", path);
|
||||
return false;
|
||||
}
|
||||
if (claims.get<bool>() != payload.get<bool>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get<bool>(), payload.get<bool>());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (claims.is<double>())
|
||||
{
|
||||
if (!payload.is<double>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match key type 'double' in claims '{}'", path);
|
||||
return false;
|
||||
}
|
||||
if (claims.get<double>() != payload.get<double>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get<double>(), payload.get<double>());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (claims.is<std::string>())
|
||||
{
|
||||
if (!payload.is<std::string>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match key type 'std::string' in claims '{}'", path);
|
||||
return false;
|
||||
}
|
||||
if (claims.get<std::string>() != payload.get<std::string>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match the value in the '{}' assertions. Expected '{}' but given '{}'", path, claims.get<std::string>(), payload.get<std::string>());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#ifdef PICOJSON_USE_INT64
|
||||
if (claims.is<int64_t>())
|
||||
{
|
||||
if (!payload.is<int64_t>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match key type 'int64_t' in claims '{}'", path);
|
||||
return false;
|
||||
}
|
||||
if (claims.get<int64_t>() != payload.get<int64_t>())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "JWT payload does not match the value in claims '{}'. Expected '{}' but given '{}'", path, claims.get<int64_t>(), payload.get<int64_t>());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
LOG_ERROR(getLogger("JWTAuthentication"), "JWT claim '{}' does not match any known type", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool check_claims(const String &claims, const picojson::value::object &payload)
|
||||
{
|
||||
if (claims.empty())
|
||||
return true;
|
||||
picojson::value json;
|
||||
auto errors = picojson::parse(json, claims);
|
||||
if (!errors.empty())
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "Bad JWT claims: {}", errors);
|
||||
if (!json.is<picojson::object>())
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "Bad JWT claims: is not an object");
|
||||
return check_claims(json.get<picojson::value::object>(), payload, "");
|
||||
}
|
||||
|
||||
std::map<String, Field> stringify_params(const picojson::value ¶ms, const String &path);
|
||||
|
||||
std::map<String, Field> stringify_params(const picojson::value::array ¶ms, const String &path)
|
||||
{
|
||||
std::map<String, Field> result;
|
||||
for (size_t i = 0; i < params.size(); ++i)
|
||||
{
|
||||
const auto tmp_result = stringify_params(params.at(i), path + "[" + std::to_string(i) + "]");
|
||||
result.insert(tmp_result.begin(), tmp_result.end());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::map<String, Field> stringify_params(const picojson::value::object ¶ms, const String &path)
|
||||
{
|
||||
auto add_path = String(path);
|
||||
if (!add_path.empty())
|
||||
add_path = add_path + ".";
|
||||
std::map<String, Field> result;
|
||||
for (const auto &it : params)
|
||||
{
|
||||
const auto tmp_result = stringify_params(it.second, add_path + it.first);
|
||||
result.insert(tmp_result.begin(), tmp_result.end());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::map<String, Field> stringify_params(const picojson::value ¶ms, const String &path)
|
||||
{
|
||||
std::map<String, Field> result;
|
||||
if (params.is<picojson::array>())
|
||||
return stringify_params(params.get<picojson::array>(), path);
|
||||
if (params.is<picojson::object>())
|
||||
return stringify_params(params.get<picojson::object>(), path);
|
||||
if (params.is<bool>())
|
||||
{
|
||||
result[path] = Field(params.get<bool>());
|
||||
return result;
|
||||
}
|
||||
if (params.is<std::string>())
|
||||
{
|
||||
result[path] = Field(params.get<std::string>());
|
||||
return result;
|
||||
}
|
||||
if (params.is<double>())
|
||||
{
|
||||
result[path] = Field(params.get<double>());
|
||||
return result;
|
||||
}
|
||||
#ifdef PICOJSON_USE_INT64
|
||||
if (params.is<int64_t>())
|
||||
{
|
||||
result[path] = Field(params.get<int64_t>());
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
void IJWTVerifier::init(const JWTVerifierParams &_params)
|
||||
{
|
||||
params = _params;
|
||||
}
|
||||
|
||||
bool IJWTVerifier::verify(const String &claims, const String &token, SettingsChanges & settings) const
|
||||
{
|
||||
// try
|
||||
// {
|
||||
auto decoded_jwt = jwt::decode(token);
|
||||
if (!verify_impl(decoded_jwt))
|
||||
return false;
|
||||
if (!check_claims(claims, decoded_jwt.get_payload_json()))
|
||||
return false;
|
||||
if (params.settings_key.empty())
|
||||
return true;
|
||||
const auto &payload_obj = decoded_jwt.get_payload_json();
|
||||
const auto &payload_settings = payload_obj.at(params.settings_key);
|
||||
const auto string_settings = stringify_params(payload_settings, "");
|
||||
for (const auto &it : string_settings)
|
||||
settings.insertSetting(it.first, it.second);
|
||||
return true;
|
||||
// }
|
||||
// catch (const std::exception &ex)
|
||||
// {
|
||||
// throw Exception(ErrorCodes::JWT_ERROR, "{}: Failed to validate JWT with exception {}", name, ex.what());
|
||||
// }
|
||||
}
|
||||
|
||||
void SimpleJWTVerifierParams::validate() const
|
||||
{
|
||||
auto lower_algo = Poco::toLower(algo);
|
||||
if (lower_algo == "none")
|
||||
return;
|
||||
if (algo == "ps256" ||
|
||||
algo == "ps384" ||
|
||||
algo == "ps512" ||
|
||||
algo == "ed25519" ||
|
||||
algo == "ed448" ||
|
||||
algo == "rs256" ||
|
||||
algo == "rs384" ||
|
||||
algo == "rs512" ||
|
||||
algo == "es256" ||
|
||||
algo == "es256k" ||
|
||||
algo == "es384" ||
|
||||
algo == "es512" )
|
||||
{
|
||||
if (!public_key.empty())
|
||||
return;
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "`public_key` parameter required for {}", algo);
|
||||
}
|
||||
if (algo == "hs256" ||
|
||||
algo == "hs384" ||
|
||||
algo == "hs512" )
|
||||
{
|
||||
if (!single_key.empty())
|
||||
return;
|
||||
throw DB::Exception(ErrorCodes::JWT_ERROR, "`single_key` parameter required for {}", algo);
|
||||
}
|
||||
throw DB::Exception(ErrorCodes::JWT_ERROR, "Unknown algorithm {}", algo);
|
||||
}
|
||||
|
||||
SimpleJWTVerifier::SimpleJWTVerifier(const String & _name)
|
||||
: IJWTVerifier(_name)
|
||||
, verifier(jwt::verify())
|
||||
{}
|
||||
|
||||
void SimpleJWTVerifier::init(const SimpleJWTVerifierParams & _params)
|
||||
{
|
||||
auto algo = Poco::toLower(_params.algo);
|
||||
|
||||
IJWTVerifier::init(_params);
|
||||
verifier = jwt::verify();
|
||||
if (algo == "none")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::none());
|
||||
else if (algo == "ps256")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::ps256(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "ps384")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::ps384(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "ps512")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::ps512(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "ed25519")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::ed25519(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "ed448")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::ed448(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "rs256")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::rs256(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "rs384")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::rs384(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "rs512")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::rs512(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "es256")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::es256(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "es256k")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::es256k(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "es384")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::es384(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo == "es512")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::es512(_params.public_key, _params.private_key, _params.private_key_password, _params.private_key_password));
|
||||
else if (algo.starts_with("hs"))
|
||||
{
|
||||
auto key = _params.single_key;
|
||||
if (_params.single_key_in_base64)
|
||||
key = base64Decode(key);
|
||||
if (algo == "hs256")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::hs256(key));
|
||||
else if (algo == "hs384")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::hs384(key));
|
||||
else if (algo == "hs512")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::hs512(key));
|
||||
else
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "Unknown algorithm {}", _params.algo);
|
||||
}
|
||||
else
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "Unknown algorithm {}", _params.algo);
|
||||
}
|
||||
|
||||
bool SimpleJWTVerifier::verify_impl(const jwt::decoded_jwt<jwt::traits::kazuho_picojson> &token) const
|
||||
{
|
||||
verifier.verify(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
JWKSVerifier::JWKSVerifier(const String & _name, std::shared_ptr<IJWKSProvider> _provider)
|
||||
: IJWTVerifier(_name)
|
||||
, provider(_provider)
|
||||
{}
|
||||
|
||||
bool JWKSVerifier::verify_impl(const jwt::decoded_jwt<jwt::traits::kazuho_picojson> &token) const
|
||||
{
|
||||
auto jwk = provider->getJWKS().get_jwk(token.get_key_id());
|
||||
auto subject = token.get_subject();
|
||||
auto algo = Poco::toLower(token.get_algorithm());
|
||||
auto verifier = jwt::verify();
|
||||
String public_key;
|
||||
|
||||
try
|
||||
{
|
||||
auto issuer = token.get_issuer();
|
||||
auto x5c = jwk.get_x5c_key_value();
|
||||
|
||||
if (!x5c.empty() && !issuer.empty())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "{}: Verifying {} with 'x5c' key", name, subject);
|
||||
public_key = jwt::helper::convert_base64_der_to_pem(x5c);
|
||||
}
|
||||
}
|
||||
catch (const jwt::error::claim_not_present_exception &)
|
||||
{
|
||||
/// issuer or x5c was not specified, simply do not verify against them
|
||||
}
|
||||
catch (const std::bad_cast &)
|
||||
{
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "Invalid claim value type: must be string");
|
||||
}
|
||||
|
||||
if (public_key.empty())
|
||||
{
|
||||
LOG_TRACE(getLogger("JWTAuthentication"), "{}: `issuer` or `x5c` not present, verifying {} with RSA components", name, subject);
|
||||
const auto modulus = jwk.get_jwk_claim("n").as_string();
|
||||
const auto exponent = jwk.get_jwk_claim("e").as_string();
|
||||
public_key = jwt::helper::create_public_key_from_rsa_components(modulus, exponent);
|
||||
}
|
||||
|
||||
if (algo == "rs256")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::rs256(public_key, "", "", ""));
|
||||
else if (algo == "rs384")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::rs384(public_key, "", "", ""));
|
||||
else if (algo == "rs512")
|
||||
verifier = verifier.allow_algorithm(jwt::algorithm::rs512(public_key, "", "", ""));
|
||||
else
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "Unknown algorithm {}", algo);
|
||||
verifier = verifier.leeway(60UL); // value in seconds, add some to compensate timeout
|
||||
verifier.verify(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
JWKSClient::JWKSClient(const JWKSAuthClientParams & params_)
|
||||
: HTTPAuthClient<JWKSResponseParser>(params_)
|
||||
, m_refresh_ms(params_.refresh_ms)
|
||||
{
|
||||
}
|
||||
|
||||
JWKSClient::~JWKSClient() = default;
|
||||
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> JWKSClient::getJWKS()
|
||||
{
|
||||
{
|
||||
std::shared_lock lock(m_update_mutex);
|
||||
auto now = std::chrono::high_resolution_clock::now();
|
||||
auto diff = std::chrono::duration<double, std::milli>(now - m_last_request_send).count();
|
||||
if (diff < m_refresh_ms)
|
||||
{
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> result(m_jwks);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
std::unique_lock lock(m_update_mutex);
|
||||
auto now = std::chrono::high_resolution_clock::now();
|
||||
auto diff = std::chrono::duration<double, std::milli>(now - m_last_request_send).count();
|
||||
if (diff < m_refresh_ms)
|
||||
{
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> result(m_jwks);
|
||||
return result;
|
||||
}
|
||||
Poco::Net::HTTPRequest request{Poco::Net::HTTPRequest::HTTP_GET, this->getURI().getPathAndQuery()};
|
||||
auto result = authenticateRequest(request);
|
||||
m_jwks = std::move(result.keys);
|
||||
if (result.is_ok)
|
||||
{
|
||||
m_last_request_send = std::chrono::high_resolution_clock::now();
|
||||
}
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> results(m_jwks);
|
||||
return results;
|
||||
}
|
||||
|
||||
JWKSResponseParser::Result
|
||||
JWKSResponseParser::parse(const Poco::Net::HTTPResponse & response, std::istream * body_stream) const
|
||||
{
|
||||
Result result;
|
||||
|
||||
if (response.getStatus() != Poco::Net::HTTPResponse::HTTPStatus::HTTP_OK)
|
||||
return result;
|
||||
result.is_ok = true;
|
||||
|
||||
if (!body_stream)
|
||||
return result;
|
||||
|
||||
try
|
||||
{
|
||||
String response_data;
|
||||
Poco::StreamCopier::copyToString(*body_stream, response_data);
|
||||
auto keys = jwt::parse_jwks(response_data);
|
||||
result.keys = std::move(keys);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
LOG_INFO(getLogger("JWKSAuthentication"), "Failed to parse jwks from authentication response. Skip it.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void StaticJWKSParams::validate() const
|
||||
{
|
||||
if (static_jwks.empty() && static_jwks_file.empty())
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "`static_jwks` or `static_jwks_file` keys must be present in configuration");
|
||||
if (!static_jwks.empty() && !static_jwks_file.empty())
|
||||
throw Exception(ErrorCodes::JWT_ERROR, "`static_jwks` and `static_jwks_file` keys cannot both be present in configuration");
|
||||
}
|
||||
|
||||
void StaticJWKS::init(const StaticJWKSParams& params)
|
||||
{
|
||||
params.validate();
|
||||
String content = String(params.static_jwks);
|
||||
if (!params.static_jwks_file.empty())
|
||||
{
|
||||
std::ifstream ifs(params.static_jwks_file);
|
||||
content = String((std::istreambuf_iterator<char>(ifs)), (std::istreambuf_iterator<char>()));
|
||||
}
|
||||
auto keys = jwt::parse_jwks(content);
|
||||
jwks = std::move(keys);
|
||||
}
|
||||
|
||||
}
|
137
src/Access/JWTVerifier.h
Normal file
137
src/Access/JWTVerifier.h
Normal file
@ -0,0 +1,137 @@
|
||||
#pragma once
|
||||
|
||||
#include <base/types.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <shared_mutex>
|
||||
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <jwt-cpp/traits/kazuho-picojson/traits.h>
|
||||
|
||||
#include "Access/HTTPAuthClient.h"
|
||||
|
||||
namespace DB
|
||||
{
|
||||
|
||||
class SettingsChanges;
|
||||
|
||||
struct JWTVerifierParams
|
||||
{
|
||||
String settings_key;
|
||||
};
|
||||
|
||||
class IJWTVerifier
|
||||
{
|
||||
public:
|
||||
explicit IJWTVerifier(const String & _name)
|
||||
: name(_name)
|
||||
{}
|
||||
void init(const JWTVerifierParams &_params);
|
||||
bool verify(const String &claims, const String &token, SettingsChanges & settings) const;
|
||||
virtual ~IJWTVerifier() = default;
|
||||
protected:
|
||||
virtual bool verify_impl(const jwt::decoded_jwt<jwt::traits::kazuho_picojson> &token) const = 0;
|
||||
JWTVerifierParams params;
|
||||
const String name;
|
||||
};
|
||||
|
||||
struct SimpleJWTVerifierParams:
|
||||
public JWTVerifierParams
|
||||
{
|
||||
String algo;
|
||||
String single_key;
|
||||
bool single_key_in_base64;
|
||||
String public_key;
|
||||
String private_key;
|
||||
String public_key_password;
|
||||
String private_key_password;
|
||||
void validate() const;
|
||||
};
|
||||
|
||||
class SimpleJWTVerifier: public IJWTVerifier
|
||||
{
|
||||
public:
|
||||
explicit SimpleJWTVerifier(const String & _name);
|
||||
void init(const SimpleJWTVerifierParams & _params);
|
||||
private:
|
||||
bool verify_impl(const jwt::decoded_jwt<jwt::traits::kazuho_picojson> &token) const override;
|
||||
jwt::verifier<jwt::default_clock, jwt::traits::kazuho_picojson> verifier;
|
||||
};
|
||||
|
||||
class IJWKSProvider
|
||||
{
|
||||
public:
|
||||
virtual ~IJWKSProvider() = default;
|
||||
virtual jwt::jwks<jwt::traits::kazuho_picojson> getJWKS() = 0;
|
||||
};
|
||||
|
||||
class JWKSVerifier: public IJWTVerifier
|
||||
{
|
||||
public:
|
||||
explicit JWKSVerifier(const String & _name, std::shared_ptr<IJWKSProvider> _provider);
|
||||
private:
|
||||
bool verify_impl(const jwt::decoded_jwt<jwt::traits::kazuho_picojson> &token) const override;
|
||||
|
||||
std::shared_ptr<IJWKSProvider> provider;
|
||||
};
|
||||
|
||||
struct JWKSAuthClientParams: public HTTPAuthClientParams
|
||||
{
|
||||
size_t refresh_ms;
|
||||
};
|
||||
|
||||
class JWKSResponseParser
|
||||
{
|
||||
static constexpr auto settings_key = "settings";
|
||||
public:
|
||||
struct Result
|
||||
{
|
||||
bool is_ok = false;
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> keys;
|
||||
};
|
||||
|
||||
Result parse(const Poco::Net::HTTPResponse & response, std::istream * body_stream) const;
|
||||
};
|
||||
|
||||
class JWKSClient: public IJWKSProvider,
|
||||
private HTTPAuthClient<JWKSResponseParser>
|
||||
{
|
||||
public:
|
||||
explicit JWKSClient(const JWKSAuthClientParams & params_);
|
||||
~JWKSClient() override;
|
||||
|
||||
JWKSClient(const JWKSClient &) = delete;
|
||||
JWKSClient(JWKSClient &&) = delete;
|
||||
JWKSClient & operator= (const JWKSClient &) = delete;
|
||||
JWKSClient & operator= (JWKSClient &&) = delete;
|
||||
private:
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> getJWKS() override;
|
||||
|
||||
size_t m_refresh_ms;
|
||||
|
||||
std::shared_mutex m_update_mutex;
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> m_jwks;
|
||||
std::chrono::time_point<std::chrono::high_resolution_clock> m_last_request_send;
|
||||
};
|
||||
|
||||
struct StaticJWKSParams
|
||||
{
|
||||
String static_jwks;
|
||||
String static_jwks_file;
|
||||
void validate() const;
|
||||
};
|
||||
|
||||
class StaticJWKS: public IJWKSProvider
|
||||
{
|
||||
public:
|
||||
void init(const StaticJWKSParams& params);
|
||||
private:
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> getJWKS() override
|
||||
{
|
||||
return jwks;
|
||||
}
|
||||
jwt::jwks<jwt::traits::kazuho_picojson> jwks;
|
||||
};
|
||||
|
||||
}
|
@ -456,7 +456,8 @@ std::optional<AuthResult> LDAPAccessStorage::authenticateImpl(
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool throw_if_user_not_exists,
|
||||
bool /* allow_no_password */,
|
||||
bool /* allow_plaintext_password */) const
|
||||
bool /* allow_plaintext_password */,
|
||||
bool /* allow_jwt */) const
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
auto id = memory_storage.find<User>(credentials.getUserName());
|
||||
|
@ -48,7 +48,7 @@ private: // IAccessStorage implementations.
|
||||
std::vector<UUID> findAllImpl(AccessEntityType type) const override;
|
||||
AccessEntityPtr readImpl(const UUID & id, bool throw_if_not_exists) const override;
|
||||
std::optional<std::pair<String, AccessEntityType>> readNameWithTypeImpl(const UUID & id, bool throw_if_not_exists) const override;
|
||||
std::optional<AuthResult> authenticateImpl(const Credentials & credentials, const Poco::Net::IPAddress & address, const ExternalAuthenticators & external_authenticators, bool throw_if_user_not_exists, bool allow_no_password, bool allow_plaintext_password) const override;
|
||||
std::optional<AuthResult> authenticateImpl(const Credentials & credentials, const Poco::Net::IPAddress & address, const ExternalAuthenticators & external_authenticators, bool throw_if_user_not_exists, bool allow_no_password, bool allow_plaintext_password, bool allow_jwt) const override;
|
||||
|
||||
void setConfiguration(const Poco::Util::AbstractConfiguration & config, const String & prefix);
|
||||
void processRoleChange(const UUID & id, const AccessEntityPtr & entity);
|
||||
|
@ -441,7 +441,7 @@ std::optional<AuthResult>
|
||||
MultipleAccessStorage::authenticateImpl(const Credentials & credentials, const Poco::Net::IPAddress & address,
|
||||
const ExternalAuthenticators & external_authenticators,
|
||||
bool throw_if_user_not_exists,
|
||||
bool allow_no_password, bool allow_plaintext_password) const
|
||||
bool allow_no_password, bool allow_plaintext_password, bool allow_jwt) const
|
||||
{
|
||||
auto storages = getStoragesInternal();
|
||||
for (size_t i = 0; i != storages->size(); ++i)
|
||||
@ -450,7 +450,7 @@ MultipleAccessStorage::authenticateImpl(const Credentials & credentials, const P
|
||||
bool is_last_storage = (i == storages->size() - 1);
|
||||
auto auth_result = storage->authenticate(credentials, address, external_authenticators,
|
||||
(throw_if_user_not_exists && is_last_storage),
|
||||
allow_no_password, allow_plaintext_password);
|
||||
allow_no_password, allow_plaintext_password, allow_jwt);
|
||||
if (auth_result)
|
||||
{
|
||||
std::lock_guard lock{mutex};
|
||||
|
@ -70,7 +70,7 @@ protected:
|
||||
bool insertImpl(const UUID & id, const AccessEntityPtr & entity, bool replace_if_exists, bool throw_if_exists) override;
|
||||
bool removeImpl(const UUID & id, bool throw_if_not_exists) override;
|
||||
bool updateImpl(const UUID & id, const UpdateFunc & update_func, bool throw_if_not_exists) override;
|
||||
std::optional<AuthResult> authenticateImpl(const Credentials & credentials, const Poco::Net::IPAddress & address, const ExternalAuthenticators & external_authenticators, bool throw_if_user_not_exists, bool allow_no_password, bool allow_plaintext_password) const override;
|
||||
std::optional<AuthResult> authenticateImpl(const Credentials & credentials, const Poco::Net::IPAddress & address, const ExternalAuthenticators & external_authenticators, bool throw_if_user_not_exists, bool allow_no_password, bool allow_plaintext_password, bool allow_jwt) const override;
|
||||
|
||||
private:
|
||||
using Storages = std::vector<StoragePtr>;
|
||||
|
@ -14,6 +14,7 @@
|
||||
#include <Common/StringUtils.h>
|
||||
#include <Common/quoteString.h>
|
||||
#include <Common/transformEndianness.h>
|
||||
#include "Access/Credentials.h"
|
||||
#include <Core/Settings.h>
|
||||
#include <Interpreters/executeQuery.h>
|
||||
#include <Parsers/Access/ASTGrantQuery.h>
|
||||
@ -118,7 +119,8 @@ namespace
|
||||
const std::unordered_set<UUID> & allowed_profile_ids,
|
||||
const std::unordered_set<UUID> & allowed_role_ids,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password)
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt)
|
||||
{
|
||||
auto user = std::make_shared<User>();
|
||||
user->setName(user_name);
|
||||
@ -129,6 +131,7 @@ namespace
|
||||
bool has_password_double_sha1_hex = config.has(user_config + ".password_double_sha1_hex");
|
||||
bool has_ldap = config.has(user_config + ".ldap");
|
||||
bool has_kerberos = config.has(user_config + ".kerberos");
|
||||
bool has_jwt = config.has(user_config + ".jwt");
|
||||
|
||||
const auto certificates_config = user_config + ".ssl_certificates";
|
||||
bool has_certificates = config.has(certificates_config);
|
||||
@ -140,18 +143,18 @@ 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_jwt;
|
||||
|
||||
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', "
|
||||
"'http_authentication' are used to specify authentication info for user {}. "
|
||||
"'http_authentication', 'jwt' are used to specify authentication info for user {}. "
|
||||
"Must be only one of them.", user_name);
|
||||
|
||||
if (num_password_fields < 1)
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Either 'password' or 'password_sha256_hex' "
|
||||
"or 'password_double_sha1_hex' or 'no_password' or 'ldap' or 'kerberos "
|
||||
"or 'ssl_certificates' or 'ssh_keys' or 'http_authentication' must be specified for user {}.", user_name);
|
||||
"or 'ssl_certificates' or 'ssh_keys' or 'http_authentication' or 'jwt' must be specified for user {}.", user_name);
|
||||
|
||||
if (has_password_plaintext)
|
||||
{
|
||||
@ -265,6 +268,12 @@ namespace
|
||||
auto scheme = config.getString(http_auth_config + ".scheme");
|
||||
user->auth_data.setHTTPAuthenticationScheme(parseHTTPAuthenticationScheme(scheme));
|
||||
}
|
||||
else if (has_jwt)
|
||||
{
|
||||
if (!allow_jwt)
|
||||
throw Exception(ErrorCodes::BAD_ARGUMENTS, "JWT auth not allowed because JWT not configured");
|
||||
user->auth_data = AuthenticationData{AuthenticationType::JWT};
|
||||
}
|
||||
|
||||
auto auth_type = user->auth_data.getType();
|
||||
if (((auth_type == AuthenticationType::NO_PASSWORD) && !allow_no_password) ||
|
||||
@ -419,7 +428,8 @@ namespace
|
||||
const std::unordered_set<UUID> & allowed_profile_ids,
|
||||
const std::unordered_set<UUID> & allowed_role_ids,
|
||||
bool allow_no_password,
|
||||
bool allow_plaintext_password)
|
||||
bool allow_plaintext_password,
|
||||
bool allow_jwt)
|
||||
{
|
||||
Poco::Util::AbstractConfiguration::Keys user_names;
|
||||
config.keys("users", user_names);
|
||||
@ -430,7 +440,7 @@ namespace
|
||||
{
|
||||
try
|
||||
{
|
||||
users.push_back(parseUser(config, user_name, allowed_profile_ids, allowed_role_ids, allow_no_password, allow_plaintext_password));
|
||||
users.push_back(parseUser(config, user_name, allowed_profile_ids, allowed_role_ids, allow_no_password, allow_plaintext_password, allow_jwt));
|
||||
}
|
||||
catch (Exception & e)
|
||||
{
|
||||
@ -845,9 +855,10 @@ void UsersConfigAccessStorage::parseFromConfig(const Poco::Util::AbstractConfigu
|
||||
auto allowed_role_ids = getAllowedIDs(config, "roles", AccessEntityType::ROLE);
|
||||
bool no_password_allowed = access_control.isNoPasswordAllowed();
|
||||
bool plaintext_password_allowed = access_control.isPlaintextPasswordAllowed();
|
||||
bool allow_jwt = access_control.isJWTAllowed();
|
||||
|
||||
std::vector<std::pair<UUID, AccessEntityPtr>> all_entities;
|
||||
for (const auto & entity : parseUsers(config, allowed_profile_ids, allowed_role_ids, no_password_allowed, plaintext_password_allowed))
|
||||
for (const auto & entity : parseUsers(config, allowed_profile_ids, allowed_role_ids, no_password_allowed, plaintext_password_allowed, allow_jwt))
|
||||
all_entities.emplace_back(generateID(*entity), entity);
|
||||
for (const auto & entity : parseQuotas(config))
|
||||
all_entities.emplace_back(generateID(*entity), entity);
|
||||
|
@ -352,6 +352,7 @@ target_link_libraries(clickhouse_common_io
|
||||
ch_contrib::zlib
|
||||
pcg_random
|
||||
Poco::Foundation
|
||||
ch_contrib::jwt-cpp
|
||||
)
|
||||
|
||||
if (TARGET ch_contrib::libfiu)
|
||||
|
@ -609,6 +609,7 @@
|
||||
M(728, UNEXPECTED_DATA_TYPE) \
|
||||
M(729, ILLEGAL_TIME_SERIES_TAGS) \
|
||||
M(730, REFRESH_FAILED) \
|
||||
M(731, JWT_ERROR) \
|
||||
\
|
||||
M(900, DISTRIBUTED_CACHE_ERROR) \
|
||||
M(901, CANNOT_USE_DISTRIBUTED_CACHE) \
|
||||
|
@ -86,6 +86,8 @@ static constexpr auto DBMS_MIN_REVISION_WITH_ROWS_BEFORE_AGGREGATION = 54469;
|
||||
/// Packets size header
|
||||
static constexpr auto DBMS_MIN_PROTOCOL_VERSION_WITH_CHUNKED_PACKETS = 54470;
|
||||
|
||||
static constexpr auto DBMS_MIN_REVISION_WITH_JWT_AUTHENTICATION = 54471;
|
||||
|
||||
/// Version of ClickHouse TCP protocol.
|
||||
///
|
||||
/// Should be incremented manually on protocol changes.
|
||||
@ -93,6 +95,6 @@ static constexpr auto DBMS_MIN_PROTOCOL_VERSION_WITH_CHUNKED_PACKETS = 54470;
|
||||
/// NOTE: DBMS_TCP_PROTOCOL_VERSION has nothing common with VERSION_REVISION,
|
||||
/// later is just a number for server version (one number instead of commit SHA)
|
||||
/// for simplicity (sometimes it may be more convenient in some use cases).
|
||||
static constexpr auto DBMS_TCP_PROTOCOL_VERSION = 54470;
|
||||
static constexpr auto DBMS_TCP_PROTOCOL_VERSION = 54471;
|
||||
|
||||
}
|
||||
|
@ -91,8 +91,11 @@ void ASTAuthenticationData::formatImpl(const FormatSettings & settings, FormatSt
|
||||
}
|
||||
case AuthenticationType::JWT:
|
||||
{
|
||||
prefix = "CLAIMS";
|
||||
parameter = true;
|
||||
if (!children.empty())
|
||||
{
|
||||
prefix = "CLAIMS";
|
||||
parameter = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AuthenticationType::LDAP:
|
||||
|
@ -17,7 +17,7 @@ class ASTAuthenticationData;
|
||||
|
||||
|
||||
/** CREATE USER [IF NOT EXISTS | OR REPLACE] name
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']|{WITH jwt [CLAIMS 'json_object']}}]
|
||||
* [HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
|
||||
* [DEFAULT ROLE role [,...]]
|
||||
* [DEFAULT DATABASE database | NONE]
|
||||
@ -26,7 +26,7 @@ class ASTAuthenticationData;
|
||||
*
|
||||
* ALTER USER [IF EXISTS] name
|
||||
* [RENAME TO new_name]
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']|{WITH jwt [CLAIMS 'json_object']}}]
|
||||
* [[ADD|DROP] HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
|
||||
* [DEFAULT ROLE role [,...] | ALL | ALL EXCEPT role [,...] ]
|
||||
* [DEFAULT DATABASE database | NONE]
|
||||
|
@ -67,6 +67,7 @@ namespace
|
||||
bool expect_ssl_cert_subjects = false;
|
||||
bool expect_public_ssh_key = false;
|
||||
bool expect_http_auth_server = false;
|
||||
bool expect_claims = false;
|
||||
|
||||
if (ParserKeyword{Keyword::WITH}.ignore(pos, expected))
|
||||
{
|
||||
@ -88,7 +89,8 @@ namespace
|
||||
expect_http_auth_server = true;
|
||||
else if (check_type != AuthenticationType::NO_PASSWORD)
|
||||
expect_password = true;
|
||||
|
||||
else if (check_type != AuthenticationType::JWT)
|
||||
expect_claims = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -125,6 +127,7 @@ namespace
|
||||
ASTPtr http_auth_scheme;
|
||||
ASTPtr ssl_cert_subjects;
|
||||
std::optional<String> ssl_cert_subject_type;
|
||||
ASTPtr jwt_claims;
|
||||
|
||||
if (expect_password || expect_hash)
|
||||
{
|
||||
@ -189,6 +192,14 @@ namespace
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (expect_claims)
|
||||
{
|
||||
if (ParserKeyword{Keyword::CLAIMS}.ignore(pos, expected))
|
||||
{
|
||||
if (!ParserStringAndSubstitution{}.parse(pos, jwt_claims, expected))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
auth_data = std::make_shared<ASTAuthenticationData>();
|
||||
|
||||
@ -214,6 +225,9 @@ namespace
|
||||
if (http_auth_scheme)
|
||||
auth_data->children.push_back(std::move(http_auth_scheme));
|
||||
|
||||
if (jwt_claims)
|
||||
auth_data->children.push_back(std::move(jwt_claims));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ namespace DB
|
||||
{
|
||||
/** Parses queries like
|
||||
* CREATE USER [IF NOT EXISTS | OR REPLACE] name
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}|{WITH jwt}]
|
||||
* [HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
|
||||
* [DEFAULT ROLE role [,...]]
|
||||
* [SETTINGS variable [= value] [MIN [=] min_value] [MAX [=] max_value] [CONST|READONLY|WRITABLE|CHANGEABLE_IN_READONLY] | PROFILE 'profile_name'] [,...]
|
||||
@ -15,7 +15,7 @@ namespace DB
|
||||
*
|
||||
* ALTER USER [IF EXISTS] name
|
||||
* [RENAME TO new_name]
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}]
|
||||
* [NOT IDENTIFIED | IDENTIFIED {[WITH {no_password|plaintext_password|sha256_password|sha256_hash|double_sha1_password|double_sha1_hash}] BY {'password'|'hash'}}|{WITH ldap SERVER 'server_name'}|{WITH kerberos [REALM 'realm']}|{WITH jwt}]
|
||||
* [[ADD|DROP] HOST {LOCAL | NAME 'name' | REGEXP 'name_regexp' | IP 'address' | LIKE 'pattern'} [,...] | ANY | NONE]
|
||||
* [DEFAULT ROLE role [,...] | ALL | ALL EXCEPT role [,...] ]
|
||||
* [SETTINGS variable [= value] [MIN [=] min_value] [MAX [=] max_value] [CONST|READONLY|WRITABLE|CHANGEABLE_IN_READONLY] | PROFILE 'profile_name'] [,...]
|
||||
|
@ -84,6 +84,7 @@ namespace DB
|
||||
MR_MACROS(CLEAR_INDEX, "CLEAR INDEX") \
|
||||
MR_MACROS(CLEAR_PROJECTION, "CLEAR PROJECTION") \
|
||||
MR_MACROS(CLEAR_STATISTICS, "CLEAR STATISTICS") \
|
||||
MR_MACROS(CLAIMS, "CLAIMS") \
|
||||
MR_MACROS(CLUSTER, "CLUSTER") \
|
||||
MR_MACROS(CLUSTERS, "CLUSTERS") \
|
||||
MR_MACROS(CN, "CN") \
|
||||
|
@ -17,7 +17,7 @@
|
||||
#include <Poco/Net/X509Certificate.h>
|
||||
#endif
|
||||
|
||||
|
||||
const String BEARER_PREFIX = "bearer ";
|
||||
namespace DB
|
||||
{
|
||||
|
||||
@ -75,6 +75,8 @@ bool authenticateUserByHTTP(
|
||||
bool has_http_credentials = request.hasCredentials();
|
||||
bool has_credentials_in_query_params = params.has("user") || params.has("password");
|
||||
|
||||
std::string jwt_token = request.get("X-ClickHouse-JWT-Token", request.get("Authorization", (params.has("token") ? BEARER_PREFIX + params.get("token") : "")));
|
||||
|
||||
std::string spnego_challenge;
|
||||
SSLCertificateSubjects certificate_subjects;
|
||||
|
||||
@ -137,7 +139,7 @@ bool authenticateUserByHTTP(
|
||||
if (spnego_challenge.empty())
|
||||
throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: SPNEGO challenge is empty");
|
||||
}
|
||||
else
|
||||
else if (Poco::icompare(scheme, "Bearer") < 0)
|
||||
{
|
||||
throw Exception(ErrorCodes::AUTHENTICATION_FAILED, "Invalid authentication: '{}' HTTP Authorization scheme is not supported", scheme);
|
||||
}
|
||||
@ -189,6 +191,10 @@ bool authenticateUserByHTTP(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!jwt_token.empty() && Poco::toLower(jwt_token).starts_with(BEARER_PREFIX))
|
||||
{
|
||||
current_credentials = std::make_unique<JWTCredentials>(jwt_token.substr(BEARER_PREFIX.length()));
|
||||
}
|
||||
else // I.e., now using user name and password strings ("Basic").
|
||||
{
|
||||
if (!current_credentials)
|
||||
|
@ -1560,6 +1560,10 @@ void TCPHandler::receiveHello()
|
||||
if (is_ssh_based_auth)
|
||||
user.erase(0, std::string_view(EncodedUserInfo::SSH_KEY_AUTHENTICAION_MARKER).size());
|
||||
|
||||
is_jwt_based_auth = user.starts_with(EncodedUserInfo::JWT_AUTHENTICAION_MARKER);
|
||||
if (is_jwt_based_auth)
|
||||
user.erase(0, std::string_view(EncodedUserInfo::JWT_AUTHENTICAION_MARKER).size());
|
||||
|
||||
session = makeSession();
|
||||
const auto & client_info = session->getClientInfo();
|
||||
|
||||
@ -1636,6 +1640,16 @@ void TCPHandler::receiveHello()
|
||||
}
|
||||
#endif
|
||||
|
||||
if (is_jwt_based_auth)
|
||||
{
|
||||
if (client_tcp_protocol_version < DBMS_MIN_REVISION_WITH_JWT_AUTHENTICATION)
|
||||
throw Exception(ErrorCodes::UNSUPPORTED_METHOD, "Cannot authenticate user with JWT key, because client version is too old");
|
||||
|
||||
auto cred = JWTCredentials(password);
|
||||
session->authenticate(cred, getClientAddress(client_info));
|
||||
return;
|
||||
}
|
||||
|
||||
session->authenticate(user, password, getClientAddress(client_info));
|
||||
}
|
||||
|
||||
|
@ -221,6 +221,7 @@ private:
|
||||
String default_database;
|
||||
|
||||
bool is_ssh_based_auth = false; /// authentication is via SSH pub-key challenge
|
||||
bool is_jwt_based_auth = false; /// authentication is via JWT challenge
|
||||
/// For inter-server secret (remote_server.*.secret)
|
||||
bool is_interserver_mode = false;
|
||||
bool is_interserver_authenticated = false;
|
||||
|
@ -158,6 +158,7 @@ function clone_submodules
|
||||
contrib/libfiu
|
||||
contrib/incbin
|
||||
contrib/yaml-cpp
|
||||
contrib/jwt-cpp
|
||||
)
|
||||
|
||||
git submodule sync
|
||||
|
0
tests/integration/test_jwt_auth/__init__.py
Normal file
0
tests/integration/test_jwt_auth/__init__.py
Normal file
15
tests/integration/test_jwt_auth/configs/users.xml
Normal file
15
tests/integration/test_jwt_auth/configs/users.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<clickhouse>
|
||||
<profiles>
|
||||
<default>
|
||||
</default>
|
||||
</profiles>
|
||||
<users>
|
||||
<jwt_user>
|
||||
<jwt>
|
||||
<claims>{"resource_access": "view-profile"}</claims>
|
||||
</jwt>
|
||||
<profile>default</profile>
|
||||
<quota>default</quota>
|
||||
</jwt_user>
|
||||
</users>
|
||||
</clickhouse>
|
@ -0,0 +1,12 @@
|
||||
<clickhouse>
|
||||
<jwt_verifiers>
|
||||
<single_key_validator>
|
||||
<algo>hs256</algo>
|
||||
<single_key>my_secret</single_key>
|
||||
<single_key_in_base64>false</single_key_in_base64>
|
||||
</single_key_validator>
|
||||
<!-- <static_jwks_validator>-->
|
||||
<!-- <static_jwks>{"keys": [{"kty": "RSA", "alg": "RS256", "kid": "mykid", "n": "lICGC8S5pObyASih5qfmwuclG0oKsbzY2z9vgwqyhTYQOWcqYcTjVV4aQ30qb6E0-5W6rJ-jx9zx6GuAEGMiG_aWJEdbUAMGp-L1kz4lrw5U6GlwoZIvk4wqoRwsiyc-mnDMQAmiZLBNyt3wU6YnKgYmb4O1cSzcZ5HMbImJpj4tpYjqnIazvYMn_9Pxjkl0ezLCr52av0UkWHro1H4QMVfuEoNmHuWPww9jgHn-I-La0xdOhRpAa0XnJi65dXZd4330uWjeJwt413yz881uS4n1OLOGKG8ImDcNlwU_guyvk0n0aqT0zkOAPp9_yYo13MPWmiRCfOX8ozdN7VDIJw", "e": "AQAB"}]}</static_jwks>-->
|
||||
<!-- </static_jwks_validator>-->
|
||||
</jwt_verifiers>
|
||||
</clickhouse>
|
48
tests/integration/test_jwt_auth/test.py
Normal file
48
tests/integration/test_jwt_auth/test.py
Normal file
@ -0,0 +1,48 @@
|
||||
import os
|
||||
import pytest
|
||||
from helpers.cluster import ClickHouseCluster
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
cluster = ClickHouseCluster(__file__)
|
||||
instance = cluster.add_instance(
|
||||
"instance",
|
||||
main_configs=["configs/verify_static_key.xml"],
|
||||
user_configs=["configs/users.xml"],
|
||||
)
|
||||
client = cluster.add_instance(
|
||||
"client",
|
||||
main_configs=["configs/verify_static_key.xml"],
|
||||
user_configs=["configs/users.xml"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def started_cluster():
|
||||
try:
|
||||
cluster.start()
|
||||
yield cluster
|
||||
finally:
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
def test_static_key(started_cluster):
|
||||
res = client.exec_in_container(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
f'curl -H "X-ClickHouse-JWT-Token: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqd3RfdXNlciIsInJlc291cmNlX2FjY2VzcyI6InZpZXctcHJvZmlsZSJ9.TVnAmEMZeUqG-BD2K4f3Hk6LRvCiTr28W9dbjSGzi0Q" "http://{cluster.get_instance_ip(instance.name)}:8123/?query=SELECT%20currentUser()"',
|
||||
]
|
||||
)
|
||||
assert res == "jwt_user\n"
|
||||
|
||||
|
||||
def test_static_jwks(started_cluster):
|
||||
res = client.exec_in_container(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
f'curl -H "X-ClickHouse-JWT-Token: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im15a2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqd3RfdXNlciIsInJlc291cmNlX2FjY2VzcyI6InZpZXctcHJvZmlsZSIsImlzcyI6InRlc3RfaXNzIn0.RnSXgwJBQGf9ph-HR0-tgN2ky87vvLtPZzOwSRxGwAQjLBIBSJ9t4JXB_DVK9MpVq7fV7c7_MtohX_fwqT9KhkmrRluyRvPBfImFkxmRJZMdNwQpr3RLm2Lj7NlINFIoAUnkMiNBN7SK0JA0F3ppi4CVZLahDsvWm4ddghoiAQwpqpjQIsDK3O2EWUxWRwhp28mY4auPcbutTD82Kzv1x_Zjygo1yEMqAid_4bfawkeAAERFUel3GCLrAqTNkFbXX_uWkd0bSOvPR9zzyrou0w2O0dborEHPwIMdxtJzfpymLrR_nvzH9xnt8ohhVvNojAekTnOTQrg3xQHUXAJJQA" "http://{cluster.get_instance_ip(instance.name)}:8123/?query=SELECT%20currentUser()"',
|
||||
]
|
||||
)
|
||||
assert res == "jwt_user\n"
|
@ -246,6 +246,8 @@ DockerHub
|
||||
DoubleDelta
|
||||
Doxygen
|
||||
Durre
|
||||
ECDSA
|
||||
EdDSA
|
||||
ECMA
|
||||
ETag
|
||||
Ecto
|
||||
@ -346,6 +348,7 @@ Heredoc
|
||||
HexAreaKm
|
||||
HexAreaM
|
||||
HexRing
|
||||
HMSC
|
||||
Holistics
|
||||
Homebrew
|
||||
Homebrew's
|
||||
@ -455,6 +458,8 @@ Jitter
|
||||
Joda
|
||||
JumpConsistentHash
|
||||
Jupyter
|
||||
jwks
|
||||
JWKS
|
||||
KDevelop
|
||||
KafkaAssignedPartitions
|
||||
KafkaBackgroundReads
|
||||
@ -2939,6 +2944,7 @@ utils
|
||||
uuid
|
||||
uuidv
|
||||
vCPU
|
||||
validators
|
||||
varPop
|
||||
varPopStable
|
||||
varSamp
|
||||
@ -2956,6 +2962,8 @@ vectorscan
|
||||
vendoring
|
||||
verificationDepth
|
||||
verificationMode
|
||||
verifier
|
||||
verifiers
|
||||
versionedcollapsingmergetree
|
||||
vhost
|
||||
virtualized
|
||||
|
Loading…
Reference in New Issue
Block a user