diff --git a/.gitignore b/.gitignore index a99fd14310a6..c8e79b312a87 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ lib/ pylib/src/ **/cqlshlib.xml !lib/cassandra-driver-internal-only-*.zip +!lib/puresasl-*.zip # C* debs build-stamp diff --git a/bin/cqlsh.py b/bin/cqlsh.py index c412d20ddf3c..d23be539b174 100755 --- a/bin/cqlsh.py +++ b/bin/cqlsh.py @@ -99,7 +99,6 @@ if os.environ.get('CQLSH_NO_BUNDLED', ''): ZIPLIB_DIRS = () - def find_zip(libprefix): for ziplibdir in ZIPLIB_DIRS: zips = glob(os.path.join(ziplibdir, libprefix + '*.zip')) @@ -113,7 +112,7 @@ def find_zip(libprefix): sys.path.insert(0, os.path.join(cql_zip, 'cassandra-driver-' + ver)) # the driver needs dependencies -third_parties = ('six-') +third_parties = ('six-','puresasl-') for lib in third_parties: lib_zip = find_zip(lib) @@ -145,7 +144,7 @@ def find_zip(libprefix): if os.path.isdir(cqlshlibdir): sys.path.insert(0, cqlshlibdir) -from cqlshlib import cql3handling, pylexotron, sslhandling, cqlshhandling +from cqlshlib import cql3handling, pylexotron, sslhandling, cqlshhandling, authproviderhandling from cqlshlib.copyutil import ExportTask, ImportTask from cqlshlib.displaying import (ANSI_RESET, BLUE, COLUMN_NAME_COLORS, CYAN, RED, WHITE, FormattedValue, colorme) @@ -155,6 +154,7 @@ def find_zip(libprefix): from cqlshlib.tracing import print_trace, print_trace_session from cqlshlib.util import get_file_encoding_bomsize + DEFAULT_HOST = '127.0.0.1' DEFAULT_PORT = 9042 DEFAULT_SSL = False @@ -426,7 +426,7 @@ class Shell(cmd.Cmd): default_page_size = 100 def __init__(self, hostname, port, color=False, - username=None, password=None, encoding=None, stdin=None, tty=True, + username=None, encoding=None, stdin=None, tty=True, completekey=DEFAULT_COMPLETEKEY, browser=None, use_conn=None, cqlver=None, keyspace=None, tracing_enabled=False, expand_enabled=False, @@ -442,15 +442,19 @@ def __init__(self, hostname, port, color=False, request_timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS, protocol_version=None, connect_timeout=DEFAULT_CONNECT_TIMEOUT_SECONDS, - is_subshell=False): + is_subshell=False, + auth_provider=None): cmd.Cmd.__init__(self, completekey=completekey) self.hostname = hostname self.port = port - self.auth_provider = None - if username: - if not password: + self.auth_provider = auth_provider + + if isinstance(auth_provider, PlainTextAuthProvider): + if not auth_provider.password: + # if no password is provided, we need to query the user to get one. password = getpass.getpass() - self.auth_provider = PlainTextAuthProvider(username=username, password=password) + self.auth_provider = PlainTextAuthProvider(username=auth_provider.username, password=password) + self.username = username self.keyspace = keyspace self.ssl = ssl @@ -1613,10 +1617,8 @@ def do_source(self, parsed): except IOError as e: self.printerr('Could not open %r: %s' % (fname, e)) return - username = self.auth_provider.username if self.auth_provider else None - password = self.auth_provider.password if self.auth_provider else None subshell = Shell(self.hostname, self.port, color=self.color, - username=username, password=password, + username=self.username, encoding=self.encoding, stdin=f, tty=False, use_conn=self.conn, cqlver=self.cql_version, keyspace=self.current_keyspace, tracing_enabled=self.tracing_enabled, @@ -1629,7 +1631,8 @@ def do_source(self, parsed): max_trace_wait=self.max_trace_wait, ssl=self.ssl, request_timeout=self.session.default_timeout, connect_timeout=self.conn.connect_timeout, - is_subshell=True) + is_subshell=True, + auth_provider=self.auth_provider) # duplicate coverage related settings in subshell if self.coverage: subshell.coverage = True @@ -2091,7 +2094,6 @@ def is_file_secure(filename): # This is to allow "sudo cqlsh" to work with user owned credentials file. return (uid == 0 or st.st_uid == uid) and stat.S_IMODE(st.st_mode) & (stat.S_IRGRP | stat.S_IROTH) == 0 - def read_options(cmdlineargs, environment): configs = configparser.ConfigParser() configs.read(CONFIG_FILE) @@ -2109,6 +2111,7 @@ def read_options(cmdlineargs, environment): "\nPlease use a credentials file to specify the username and password.\n", file=sys.stderr) optvalues = optparse.Values() + optvalues.username = None optvalues.password = None optvalues.credentials = os.path.expanduser(option_with_default(configs.get, 'authentication', 'credentials', @@ -2152,6 +2155,13 @@ def read_options(cmdlineargs, environment): optvalues.insecure_password_without_warning = False (options, arguments) = parser.parse_args(cmdlineargs, values=optvalues) + + # Credentials from cqlshrc will be expanded, + # credentials from the command line are also expanded if there is a space... + # we need the following so that these two scenarios will work + # cqlsh --credentials=~/.cassandra/creds + # cqlsh --credentials ~/.cassandra/creds + options.credentials = os.path.expanduser(options.credentials) if not is_file_secure(options.credentials): print("\nWarning: Credentials file '{0}' exists but is not used, because:" @@ -2164,19 +2174,26 @@ def read_options(cmdlineargs, environment): file=sys.stderr) options.credentials = '' # ConfigParser.read() will ignore unreadable files + provider = 'PlainTextAuthProvider' + if not options.username or not options.password: + if "auth_provider" in configs.sections(): + auth_provider = dict(configs.items('auth_provider')) + if "classname" in auth_provider: + provider = auth_provider["classname"] + if not options.username: credentials = configparser.ConfigParser() credentials.read(options.credentials) # use the username from credentials file but fallback to cqlshrc if username is absent from the command line parameters - options.username = option_with_default(credentials.get, 'plain_text_auth', 'username', username_from_cqlshrc) + options.username = option_with_default(credentials.get, provider, 'username', username_from_cqlshrc) if not options.password: rawcredentials = configparser.RawConfigParser() rawcredentials.read(options.credentials) # handling password in the same way as username, priority cli > credentials > cqlshrc - options.password = option_with_default(rawcredentials.get, 'plain_text_auth', 'password', password_from_cqlshrc) + options.password = option_with_default(rawcredentials.get, provider, 'password', password_from_cqlshrc) elif not options.insecure_password_without_warning: print("\nWarning: Using a password on the command line interface can be insecure." "\nRecommendation: use the credentials file to securely provide the password.\n", file=sys.stderr) @@ -2330,7 +2347,6 @@ def main(options, hostname, port): port, color=options.color, username=options.username, - password=options.password, stdin=stdin, tty=options.tty, completekey=options.completekey, @@ -2349,7 +2365,12 @@ def main(options, hostname, port): single_statement=options.execute, request_timeout=options.request_timeout, connect_timeout=options.connect_timeout, - encoding=options.encoding) + encoding=options.encoding, + auth_provider=authproviderhandling.load_auth_provider( + config_file=CONFIG_FILE, + cred_file=options.credentials, + username=options.username, + password=options.password)) except KeyboardInterrupt: sys.exit('Connection aborted.') except CQL_ERRORS as e: diff --git a/build.xml b/build.xml index 44d45bba1078..219acf77fc22 100644 --- a/build.xml +++ b/build.xml @@ -392,7 +392,7 @@ - + @@ -1328,9 +1328,10 @@ - + + diff --git a/conf/credentials.sample b/conf/credentials.sample index 9b5c644e9d06..23d0beb71bd4 100644 --- a/conf/credentials.sample +++ b/conf/credentials.sample @@ -19,7 +19,7 @@ ; ; Please ensure this file is owned by the user and is not readable by group and other users -[plain_text_auth] +[PlainTextAuthProvider] ; username = fred ; password = !!bang!!$ diff --git a/lib/puresasl-internal-only-0.6.2.zip b/lib/puresasl-internal-only-0.6.2.zip new file mode 100644 index 000000000000..8314a045f68e Binary files /dev/null and b/lib/puresasl-internal-only-0.6.2.zip differ diff --git a/pylib/cqlshlib/authproviderhandling.py b/pylib/cqlshlib/authproviderhandling.py new file mode 100644 index 000000000000..375b9202cf10 --- /dev/null +++ b/pylib/cqlshlib/authproviderhandling.py @@ -0,0 +1,148 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Handles loading of AuthProvider for CQLSH authentication. +""" + +import configparser +from importlib import import_module + + +def load_auth_provider(config_file=None, cred_file=None, username=None, password=None): + """ + Function which loads an auth provider from available config. + + Params: + * config_file ..: path to cqlsh config file (usually ~/.cassandra/cqlshrc). + * cred_file ....: path to cqlsh credentials file (default is ~/.cassandra/credentials). + * username .....: override used to return PlainTextAuthProvider according to legacy case + * password .....: override used to return PlainTextAuthProvider according to legacy case + + Will attempt to load an auth provider from available config file, using what's found in + credentials file as an override. + + Config file is expected to list module name /class in the *auth_provider* + section for dynamic loading (which is to be of type auth_provider) + + Additional params passed to the constructor of class should be specified + in the *auth_provider* section and can be freely named to match + auth provider's expectation. + + If passed username and password, function will return a PlainTextAuthProvider using the + traditional logic. It will try to use properties specified in [Auth_provider] section if + the PlainTextAuthProvider is the specified class. + + None is returned if no possible auth provider is found. + + EXAMPLE CQLSHRC: + # .. inside cqlshrc file + + [auth_provider] + module = cassandra.auth + classname = PlainTextAuthProvider + username = user1 + password = password1 + + if credentials file is specified put relevant properties under the class name + EXAMPLE + # ... inside credentials file for above example + [PlainTextAuthProvider] + password = password2 + + Credential attributes will override found in the cqlshrc. + in the above example, PlainTextAuthProvider would be used with a password of 'password2', + and username of 'user1' + """ + + def get_settings_from_config(section_name, + conf_file, + interpolation=configparser.BasicInterpolation()): + """ + Returns dict from section_name, and ini based conf_file + + * section_name ..: Section to read map of properties from (ex: [auth_provider]) + * conf_file .....: Ini based config file to read. Will return empty dict if None. + * interpolation .: Interpolation to use. + + If section is not found, or conf_file is None, function will return an empty dictionary. + """ + conf = configparser.ConfigParser(interpolation=interpolation) + if conf_file is None: + return {} + + conf.read(conf_file) + if section_name in conf.sections(): + return dict(conf.items(section_name)) + return {} + + def get_cred_file_settings(classname, creds_file): + # Since this is the credentials file we may be encountering raw strings + # as these are what passwords, or security tokens may inadvertently fall into + # we don't want interpolation to mess with them. + return get_settings_from_config( + section_name=classname, + conf_file=creds_file, + interpolation=None) + + def get_auth_provider_settings(conf_file): + return get_settings_from_config( + section_name='auth_provider', + conf_file=conf_file) + + def get_legacy_settings(legacy_username, legacy_password): + result = {} + if legacy_username is not None: + result['username'] = legacy_username + if legacy_password is not None: + result['password'] = legacy_password + return result + + provider_settings = get_auth_provider_settings(config_file) + module_name = provider_settings.pop('module', None) + class_name = provider_settings.pop('classname', None) + + # if a legacy username or password is passed to us + # regardless of what the module / class is specified, we have been overridden to using + # PlainTextAuthProvider + if username is not None or password is not None: + module_name = 'cassandra.auth' + class_name = 'PlainTextAuthProvider' + + if module_name is None or class_name is None: + return None + + credential_settings = get_cred_file_settings(class_name, cred_file) + + if module_name == 'cassandra.auth' and class_name == 'PlainTextAuthProvider': + # merge credential settings as overrides on top of provider settings. + + # we need to ensure that password property gets "set" in all cases. + # this is to support the ability to give the user a prompt in other parts + # of the code. + ctor_args = {'password': None, + **provider_settings, + **credential_settings, + **get_legacy_settings(username, password)} + else: + # merge credential settings as overrides on top of provider settings. + ctor_args = {**provider_settings, **credential_settings} + + # Load class definitions + module = import_module(module_name) + auth_provider_klass = getattr(module, class_name) + + # instantiate the class + return auth_provider_klass(**ctor_args) diff --git a/pylib/cqlshlib/test/test_authproviderhandling.py b/pylib/cqlshlib/test/test_authproviderhandling.py new file mode 100644 index 000000000000..58f1f7dfa69b --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling.py @@ -0,0 +1,132 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import os +import pytest + +from cassandra.auth import PlainTextAuthProvider +from cqlshlib.authproviderhandling import load_auth_provider + + +def construct_config_path(config_file_name): + return os.path.join(os.path.dirname(__file__), + 'test_authproviderhandling_config', + config_file_name) + + +# Simple class to help verify AuthProviders that don't need arguments. +class NoUserNamePlainTextAuthProvider(PlainTextAuthProvider): + def __init__(self): + super(NoUserNamePlainTextAuthProvider, self).__init__('', '') + + +class ComplexTextAuthProvider(PlainTextAuthProvider): + def __init__(self, username, password, extra_flag): + super(ComplexTextAuthProvider, self).__init__(username, password) + self.extra_flag = extra_flag + + +def _assert_auth_provider_matches(actual, klass, expected_props): + """ + Assert that the provider matches class and properties + * actual ..........: Thing to compare with it + * klass ...........: Class to ensure this matches to (ie PlainTextAuthProvider) + * expected_props ..: Dict of var properties to match + """ + assert isinstance(actual, klass) + assert expected_props == vars(actual) + + +class CustomAuthProviderTest(unittest.TestCase): + + def test_partial_property_example(self): + actual = load_auth_provider(construct_config_path('partial_example')) + _assert_auth_provider_matches( + actual, + NoUserNamePlainTextAuthProvider, + {"username": '', + "password": ''}) + + def test_full_property_example(self): + actual = load_auth_provider(construct_config_path('full_plain_text_example')) + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user1', + "password": 'pass1'}) + + def test_empty_example(self): + actual = load_auth_provider(construct_config_path('empty_example')) + assert actual is None + + def test_no_cqlshrc_file(self): + actual = load_auth_provider() + assert actual is None + + def test_no_classname_example(self): + actual = load_auth_provider(construct_config_path('no_classname_example')) + assert actual is None + + def test_improper_config_example(self): + with pytest.raises(ModuleNotFoundError) as error: + load_auth_provider(construct_config_path('illegal_example')) + assert error is not None + + def test_creds_example(self): + creds_file = construct_config_path('complex_auth_provider_creds') + cqlshrc = construct_config_path('complex_auth_provider') + + actual = load_auth_provider(cqlshrc, creds_file) + _assert_auth_provider_matches( + actual, + ComplexTextAuthProvider, + {"username": 'user1', + "password": 'pass2', + "extra_flag": 'flag2'}) + + def test_legacy_example_use_passed_username(self): + creds_file = construct_config_path('plain_text_partial_creds') + cqlshrc = construct_config_path('plain_text_partial_example') + + actual = load_auth_provider(cqlshrc, creds_file, 'user3') + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user3', + "password": 'pass2'}) + + def test_legacy_example_no_auth_provider_given(self): + cqlshrc = construct_config_path('empty_example') + creds_file = construct_config_path('complex_auth_provider_creds') + + actual = load_auth_provider(cqlshrc, creds_file, 'user3', 'pass3') + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user3', + "password": 'pass3'}) + + def test_legacy_example_no_password(self): + cqlshrc = construct_config_path('plain_text_partial_example') + creds_file = None + + actual = load_auth_provider(cqlshrc, creds_file, 'user3') + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user3', + "password": None}) diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider new file mode 100644 index 000000000000..879b7a66d64d --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider @@ -0,0 +1,10 @@ +; Config for a custom auth provider that uses the auth_provider field +; ComplexTextAuthProvider is a PlainTextAuthProvider in the driver which +; takes an extra field (extra_flag). +; used by unit testing + +[auth_provider] +module = cqlshlib.test.test_authproviderhandling +classname = ComplexTextAuthProvider +username = user1 +extra_flag = flag1 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_creds b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_creds new file mode 100644 index 000000000000..bb102bc1ac16 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_creds @@ -0,0 +1,3 @@ +[ComplexTextAuthProvider] +extra_flag = flag2 +password = pass2 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/empty_example b/pylib/cqlshlib/test/test_authproviderhandling_config/empty_example new file mode 100644 index 000000000000..3dfda0465439 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/empty_example @@ -0,0 +1,2 @@ +; Config for a custom auth provider that uses only the auth_provider field + diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/full_plain_text_example b/pylib/cqlshlib/test/test_authproviderhandling_config/full_plain_text_example new file mode 100644 index 000000000000..b962e63d53a3 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/full_plain_text_example @@ -0,0 +1,10 @@ +; Config for a custom auth provider that uses all possible fields +; This example loads the PlainTextAuthProvider and passes username and password to constructor +; dynamically. +; used by unit testing + +[auth_provider] +module = cassandra.auth +classname = PlainTextAuthProvider +username = user1 +password = pass1 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/illegal_example b/pylib/cqlshlib/test/test_authproviderhandling_config/illegal_example new file mode 100644 index 000000000000..615fe9f18429 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/illegal_example @@ -0,0 +1,5 @@ +; Example that shouldn't work + +[auth_provider] +module = nowhere.illegal.wrong +classname = badclass diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/no_classname_example b/pylib/cqlshlib/test/test_authproviderhandling_config/no_classname_example new file mode 100644 index 000000000000..5f65ebdc917e --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/no_classname_example @@ -0,0 +1,5 @@ +; Config for a custom auth provider that uses only the auth_provider field +; this version doesn't have a classname, but has a module name. + +[auth_provider] +module = cqlshlib.test.test_authproviderhandling diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/partial_example b/pylib/cqlshlib/test/test_authproviderhandling_config/partial_example new file mode 100644 index 000000000000..23be26e3ee9f --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/partial_example @@ -0,0 +1,8 @@ +; Config for a custom auth provider that uses only the auth_provider field +; NoUserNamePlainTextAuthProvider is a PlainTextAuthProvider in the driver which +; doesn't take a username or password. +; used by unit testing + +[auth_provider] +module = cqlshlib.test.test_authproviderhandling +classname = NoUserNamePlainTextAuthProvider diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_creds b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_creds new file mode 100644 index 000000000000..1faf24dbb1f7 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_creds @@ -0,0 +1,2 @@ +[PlainTextAuthProvider] +password = pass2 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_example b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_example new file mode 100644 index 000000000000..37baebdd2e69 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_example @@ -0,0 +1,8 @@ +; Config for a custom auth provider that uses some possible fields +; validate that the partial breakdown works successfully +; used by unit testing + +[auth_provider] +module = cassandra.auth +classname = PlainTextAuthProvider +username = user1