1
0
mirror of https://github.com/openSUSE/osc.git synced 2025-02-03 18:16:17 +01:00

Introduction of new credential management

* new module credentials.py which contains classes
  and methods to set and get passwords for different
  backends:
      - python-keyring
      - gnomekeyring
      - ConfigFile based storage

The new code should be backward compatible except a minor
change in add_section (pass and passx are not removed from
the config parser). This affects only callers that do not pass
a creds_mgr_descriptor.

On initial osc call or initial osc call on new API Url
the user now can decide where to store the password (based
on the backends available on his system)
This commit is contained in:
lethliel 2019-08-27 15:08:38 +02:00
parent c9d85ac248
commit eb3a3ef0ec
2 changed files with 315 additions and 107 deletions

View File

@ -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 <user> 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

259
osc/credentials.py Normal file
View File

@ -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