diff --git a/osc/conf.py b/osc/conf.py index a0bcb29b..bc366d3a 100644 --- a/osc/conf.py +++ b/osc/conf.py @@ -64,7 +64,9 @@ except ImportError: from . import OscConfigParser from osc import oscerr +from osc.util.helper import raw_input from .oscsslexcp import NoSecureSSLError +from osc import credentials GENERIC_KEYRING = False GNOME_KEYRING = False @@ -98,9 +100,9 @@ def _get_processors(): return 1 DEFAULTS = {'apiurl': 'https://api.opensuse.org', - 'user': 'your_username', - 'pass': 'your_password', - 'passx': '', + 'user': None, + 'pass': None, + 'passx': None, 'packagecachedir': '/var/tmp/osbuild-packagecache', 'su-wrapper': 'sudo', @@ -322,9 +324,6 @@ apiurl = %(apiurl)s # print call traces in case of errors #traceback = 1 -# use KDE/Gnome/MacOS/Windows keyring for credentials if available -#use_keyring = 1 - # check for unversioned/removed files before commit #check_filelist = 1 @@ -353,8 +352,6 @@ apiurl = %(apiurl)s #review_inherit_group = 1 [%(apiurl)s] -user = %(user)s -pass = %(pass)s # set aliases for this apiurl # aliases = foo, bar # real name used in .changes, unless the one from osc meta prj will be used @@ -366,8 +363,6 @@ pass = %(pass)s # User: mumblegack # Plain text password #pass = -# Force using of keyring for this API -#keyring = 1 """ @@ -728,7 +723,7 @@ def passx_encode(passwd): """encode plain text password to obfuscated form""" return base64.b64encode(bz2.compress(passwd.encode('ascii'))).decode("ascii") -def write_initial_config(conffile, entries, custom_template=''): +def write_initial_config(conffile, entries, custom_template='', creds_mgr_descriptor=None): """ write osc's intial configuration file. entries is a dict which contains values for the config file (e.g. { 'user' : 'username', 'pass' : 'password' } ). @@ -737,33 +732,19 @@ def write_initial_config(conffile, entries, custom_template=''): conf_template = custom_template or new_conf_template config = DEFAULTS.copy() config.update(entries) - # at this point use_keyring and gnome_keyring are str objects - if config['use_keyring'] == '1' and GENERIC_KEYRING: - protocol, host, path = \ - parse_apisrv_url(None, config['apiurl']) - keyring.set_password(host, config['user'], config['pass']) - config['pass'] = '' - config['passx'] = '' - elif config['gnome_keyring'] == '1' and GNOME_KEYRING: - protocol, host, path = \ - parse_apisrv_url(None, config['apiurl']) - gnomekeyring.set_network_password_sync( - user=config['user'], - password=config['pass'], - protocol=protocol, - server=host, - object=path) - config['user'] = '' - config['pass'] = '' - config['passx'] = '' - sio = StringIO(conf_template.strip() % config) cp = OscConfigParser.OscConfigParser(DEFAULTS) cp.readfp(sio) + cp.set(config['apiurl'], 'user', config['user']) + if creds_mgr_descriptor: + creds_mgr = creds_mgr_descriptor.create(cp) + else: + creds_mgr = _get_credentials_manager(config['apiurl'], cp) + creds_mgr.set_password(config['apiurl'], config['user'], config['pass']) write_config(conffile, cp) -def add_section(filename, url, user, passwd): +def add_section(filename, url, user, passwd, creds_mgr_descriptor=None): """ Add a section to config file for new api url. """ @@ -774,30 +755,27 @@ def add_section(filename, url, user, passwd): except OscConfigParser.configparser.DuplicateSectionError: # Section might have existed, but was empty pass - if config['use_keyring'] and GENERIC_KEYRING: - protocol, host, path = parse_apisrv_url(None, url) - keyring.set_password(host, user, passwd) - cp.set(url, 'keyring', '1') - cp.set(url, 'user', user) - cp.remove_option(url, 'pass') - cp.remove_option(url, 'passx') - elif config['gnome_keyring'] and GNOME_KEYRING: - protocol, host, path = parse_apisrv_url(None, url) - gnomekeyring.set_network_password_sync( - user=user, - password=passwd, - protocol=protocol, - server=host, - object=path) - cp.set(url, 'keyring', '1') - cp.remove_option(url, 'pass') - cp.remove_option(url, 'passx') + cp.set(url, 'user', user) + if creds_mgr_descriptor: + creds_mgr = creds_mgr_descriptor.create(cp) else: - cp.set(url, 'user', user) - cp.set(url, 'pass', passwd) + creds_mgr = _get_credentials_manager(url, cp) + creds_mgr.set_password(url, user, passwd) write_config(filename, cp) +def _get_credentials_manager(url, cp): + if cp.has_option(url, credentials.AbstractCredentialsManager.config_entry): + return credentials.create_credentials_manager(url, cp) + if config['use_keyring'] and GENERIC_KEYRING: + return credentials.get_keyring_credentials_manager(cp) + elif config['gnome_keyring'] and GNOME_KEYRING: + protocol, host, path = parse_apisrv_url(None, url) + return credentials.GnomeKeyringCredentialsManager(cp, None) + elif cp.get(url, 'passx') is not None: + return credentials.ObfuscatedConfigFileCredentialsManager(cp, None) + return credentials.PlaintextConfigFileCredentialsManager(cp, None) + def get_config(override_conffile=None, override_apiurl=None, override_debug=None, @@ -874,60 +852,19 @@ def get_config(override_conffile=None, # backward compatiblity scheme, host, path = parse_apisrv_url(config.get('scheme', 'https'), url) apiurl = urljoin(scheme, host, path) - user = None - password = None - if config['use_keyring'] and GENERIC_KEYRING: - try: - # Read from keyring lib if available - user = cp.get(url, 'user', raw=True) - password = str(keyring.get_password(host, user)) - except: - # Fallback to file based auth. - pass - elif config['gnome_keyring'] and GNOME_KEYRING: - # Read from gnome keyring if available - try: - gk_data = gnomekeyring.find_network_password_sync(protocol=scheme, server=host, object=path) - if not 'user' in gk_data[0]: - raise oscerr.ConfigError('no user found in keyring', conffile) - user = gk_data[0]['user'] - if 'password' in gk_data[0]: - password = str(gk_data[0]['password']) - else: - # this is most likely an error - print('warning: no password found in keyring', file=sys.stderr) - except gnomekeyring.NoMatchError: - # Fallback to file based auth. - pass - - if not user is None and len(user) == 0: - user = None - print('Warning: blank user in the keyring for the ' \ - 'apiurl %s.\nPlease fix your keyring entry.', file=sys.stderr) - - if user is not None and password is None: - err = ('no password defined for "%s".\nPlease fix your keyring ' - 'entry or gnome-keyring setup.\nAssuming an empty password.' - % url) - print(err, file=sys.stderr) - password = '' - - # Read credentials from config + user = cp.get(url, 'user', raw=True) + creds_mgr = _get_credentials_manager(url, cp) + # currently, this is only needed for the deprecated gnomekeyring - actually, we + # we should use the apiurl instead of url (that's what the old code did), but + # this makes things more complex (also, it is very unlikely that url and + # apiurl differ) + if user is None and hasattr(creds_mgr, 'get_user'): + user = creds_mgr.get_user(url) if user is None: - #FIXME: this could actually be the ideal spot to take defaults - #from the general section. - user = cp.get(url, 'user', raw=True) # need to set raw to prevent '%' expansion - password = cp.get(url, 'pass', raw=True) # especially on password! - try: - passwordx = passx_decode(cp.get(url, 'passx', raw=True)) # especially on password! - except: - passwordx = '' - - if password == None or password == 'your_password': - password = '' - - if user is None or user == '': - raise oscerr.ConfigError('user is blank for %s, please delete or complete the "user=" entry in %s.' % (apiurl, config['conffile']), config['conffile']) + raise oscerr.ConfigError('No user found in section %s' % url, conffile) + password = creds_mgr.get_password(url, user) + if password is None: + raise oscerr.ConfigError('No password found in section %s' % url, conffile) if cp.has_option(url, 'http_headers'): http_headers = cp.get(url, 'http_headers') @@ -1038,13 +975,25 @@ def identify_conf(): def interactive_config_setup(conffile, apiurl, initial=True): user = raw_input('Username: ') passwd = getpass.getpass() + if not credentials.has_keyring_support(): + print('To use keyrings please install python-keyring.') + creds_mgr_descriptors = credentials.get_credentials_manager_descriptors() + for i, creds_mgr_descr in enumerate(creds_mgr_descriptors, 1): + print('%d) %s (%s)' % (i, creds_mgr_descr.name(), creds_mgr_descr.description()))# + i = raw_input('Select credentials manager: ') + if not i.isdigit(): + sys.exit('Invalid selection') + i = int(i) - 1 + if i < 0 or i >= len(creds_mgr_descriptors): + sys.exit('Invalid selection') + creds_mgr_descr = creds_mgr_descriptors[i] if initial: config = {'user': user, 'pass': passwd} if apiurl: config['apiurl'] = apiurl - write_initial_config(conffile, config) + write_initial_config(conffile, config, creds_mgr_descriptor=creds_mgr_descr) else: - add_section(conffile, apiurl, user, passwd) + add_section(conffile, apiurl, user, passwd, creds_mgr_descriptor=creds_mgr_descr) # vim: sw=4 et diff --git a/osc/credentials.py b/osc/credentials.py new file mode 100644 index 00000000..a5e41a58 --- /dev/null +++ b/osc/credentials.py @@ -0,0 +1,259 @@ +import importlib +import bz2 +import base64 +try: + from urllib.parse import urlsplit +except ImportError: + from urlparse import urlsplit +try: + import keyring +except ImportError: + keyring = None +try: + import gnomekeyring +except ImportError: + gnomekeyring = None + + +class AbstractCredentialsManagerDescriptor(object): + def name(self): + raise NotImplementedError() + + def description(self): + raise NotImplementedError() + + def create(self, cp): + raise NotImplementedError() + + def __lt__(self, other): + return self.name() < other.name() + + +class AbstractCredentialsManager(object): + config_entry = 'credentials_mgr_class' + + def __init__(self, cp, options): + super(AbstractCredentialsManager, self).__init__() + self._cp = cp + self._process_options(options) + + def get_password(self, url, user, defer=True): + # If defer is True a callable can be returned + # and the password is retrieved if the callable + # is called. Implementations are free to ignore + # defer parameter and can directly return the password. + # If defer is False the password is directly returned. + raise NotImplementedError() + + def set_password(self, url, user, password): + raise NotImplementedError() + + def delete_password(self, url, user): + raise NotImplementedError() + + def _qualified_name(self): + return qualified_name(self) + + def _process_options(self, options): + pass + + +class PlaintextConfigFileCredentialsManager(AbstractCredentialsManager): + def get_password(self, url, user, defer=True): + return self._cp.get(url, 'pass', raw=True) + + def set_password(self, url, user, password): + self._cp.set(url, 'pass', password) + self._cp.set(url, self.config_entry, self._qualified_name()) + + def delete_password(self, url, user): + self._cp.remove_option(url, 'pass') + + def _process_options(self, options): + if options is not None: + raise RuntimeError('options must be None') + + +class PlaintextConfigFileDescriptor(AbstractCredentialsManagerDescriptor): + def name(self): + return 'Config file credentials manager' + + def description(self): + return 'Store the credentials in the config file (plain text)' + + def create(self, cp): + return PlaintextConfigFileCredentialsManager(cp, None) + + +class ObfuscatedConfigFileCredentialsManager( + PlaintextConfigFileCredentialsManager): + def get_password(self, url, user, defer=True): + passwd = super(self.__class__, self).get_password(url, user) + return self.decode_password(passwd) + + def set_password(self, url, user, password): + compressed_pw = bz2.compress(password.encode('ascii')) + password = base64.b64encode(compressed_pw).decode("ascii") + super(self.__class__, self).set_password(url, user, password) + + @classmethod + def decode_password(cls, password): + compressed_pw = base64.b64decode(password.encode("ascii")) + return bz2.decompress(compressed_pw).decode("ascii") + + +class ObfuscatedConfigFileDescriptor(AbstractCredentialsManagerDescriptor): + def name(self): + return 'Obfuscated Config file credentials manager' + + def description(self): + return 'Store the credentials in the config file (obfuscated)' + + def create(self, cp): + return ObfuscatedConfigFileCredentialsManager(cp, None) + + +class KeyringCredentialsManager(AbstractCredentialsManager): + def __init__(self, cp, options, appname='osc'): + super(self.__class__, self).__init__(cp, options) + self._appname = appname + + def _process_options(self, options): + if options is None: + raise RuntimeError('options may not be None') + self._backend_cls_name = options + + def _load_backend(self): + keyring_backend = keyring.core.load_keyring(self._backend_cls_name) + keyring.set_keyring(keyring_backend) + + def get_password(self, url, user, defer=True): + self._load_backend() + return keyring.get_password(self._appname, user) + + def set_password(self, url, user, password): + self._load_backend() + keyring.set_password(self._appname, user, password) + config_value = self._qualified_name() + ':' + self._backend_cls_name + self._cp.set(url, self.config_entry, config_value) + + def delete_password(self, url, user): + self._load_backend() + keyring.delete_password(self._appname, user) + + +class KeyringCredentialsDescriptor(AbstractCredentialsManagerDescriptor): + def __init__(self, keyring_backend): + self._keyring_backend = keyring_backend + + def name(self): + return self._keyring_backend.name + + def description(self): + return 'Backend provided by python-keyring' + + def create(self, cp): + qualified_backend_name = qualified_name(self._keyring_backend) + return KeyringCredentialsManager(cp, qualified_backend_name) + + +class GnomeKeyringCredentialsManager(AbstractCredentialsManager): + def get_password(self, url, user, defer=True): + gk_data = self._keyring_data(url, user) + if gk_data is None: + return None + return gk_data['password'] + + def set_password(self, url, user, password): + scheme, host, path = self._urlsplit(url) + gnomekeyring.set_network_password_sync( + user=user, + password=password, + protocol=scheme, + server=host, + object=path) + self._cp.set(url, self.config_entry, self._qualified_name()) + + def delete_password(self, url, user): + gk_data = self._keyring_data(url, user) + if gk_data is None: + return + gnomekeyring.item_delete_sync(gk_data['keyring'], gk_data['item_id']) + + def get_user(self, url): + gk_data = self._keyring_data(url, None) + if gk_data is None: + return None + return gk_data['user'] + + def _keyring_data(self, url, user): + scheme, host, path = self._urlsplit(url) + try: + entries = gnomekeyring.find_network_password_sync(protocol=scheme, + server=host, + object=path) + except gnomekeyring.NoMatchError: + return None + + for entry in entries: + if 'user' not in entry or 'password' not in entry: + continue + if user is None or entry['user'] == user: + return entry + return None + + def _urlsplit(self, url): + splitted_url = urlsplit(url) + return splitted_url.scheme, splitted_url.netloc, splitted_url.path + + +class GnomeKeyringCredentialsDescriptor(AbstractCredentialsManagerDescriptor): + def name(self): + return 'GNOME Keyring Manager (deprecated)' + + def description(self): + return 'Deprecated GNOME Keyring Manager. If you use \ + this we will send you a Dial-In modem' + + def create(self, cp): + return GnomeKeyringCredentialsManager(cp, None) + + +def get_credentials_manager_descriptors(): + if has_keyring_support(): + backend_list = keyring.backend.get_all_keyring() + else: + backend_list = [] + descriptors = [] + for backend in backend_list: + descriptors.append(KeyringCredentialsDescriptor(backend)) + descriptors.sort() + if gnomekeyring: + descriptors.append(GnomeKeyringCredentialsDescriptor()) + descriptors.append(PlaintextConfigFileDescriptor()) + descriptors.append(ObfuscatedConfigFileDescriptor()) + return descriptors + + +def get_keyring_credentials_manager(cp): + keyring_backend = keyring.get_keyring() + return KeyringCredentialsManager(cp, qualified_name(keyring_backend)) + + +def create_credentials_manager(url, cp): + config_entry = cp.get(url, AbstractCredentialsManager.config_entry) + if ':' in config_entry: + creds_mgr_cls, options = config_entry.split(':', 1) + else: + creds_mgr_cls = config_entry + options = None + mod, cls = creds_mgr_cls.rsplit('.', 1) + return getattr(importlib.import_module(mod), cls)(cp, options) + + +def qualified_name(obj): + return obj.__module__ + '.' + obj.__class__.__name__ + + +def has_keyring_support(): + return keyring is not None