From c1b65499b5da45b05e8d00252ab127e35294e122 Mon Sep 17 00:00:00 2001 From: Cyril Servant Date: Thu, 11 Jun 2020 13:59:06 +0200 Subject: [PATCH 1/2] Merge cea-hpc patches to master --- bin/ssh-ldap-pubkey | 127 ++++++++++++++++-- ssh_ldap_pubkey/__init__.py | 253 +++++++++++++++++++++++++++++++++--- 2 files changed, 352 insertions(+), 28 deletions(-) diff --git a/bin/ssh-ldap-pubkey b/bin/ssh-ldap-pubkey index 18a98d3..ca94ac9 100755 --- a/bin/ssh-ldap-pubkey +++ b/bin/ssh-ldap-pubkey @@ -6,39 +6,68 @@ ssh-ldap-pubkey - Utility to manage SSH public keys stored in LDAP. Usage: ssh-ldap-pubkey list [-H URI...] [options] + ssh-ldap-pubkey listall [-H URI...] [options] ssh-ldap-pubkey add [-H URI...] [options] FILE + ssh-ldap-pubkey sync [-H URI...] [options] FILE ssh-ldap-pubkey del [-H URI...] [options] PATTERN ssh-ldap-pubkey --help - Read public key from stdin. - FILE Path to the public key file to add. + FILE Path to the public keys file to add. PATTERN Pattern that specifies public key(s) to delete, i.e. - a complete key or just a part of it. + a complete key or just a part of it. Use '*' to + delete all public keys. Options: + -a ATTRS --attrs=ATTRS + Comma separated list of returned attributes when + listing public key(s) for all users. [default: uid] -b DN --base=DN Base DN where to search for the users' entry. If not provided, then it's read from the config file. -c FILE --conf=FILE Path of the ldap.conf (default is /etc/ldap.conf). The ldap.conf is not required when at least --base is provided. -D DN --binddn=DN DN to bind with instead of the user's DN. + -e DAYS --expire=DAYS When adding a public key: add an expiration date, when + listing public key(s): check if the key will be + expired in DAYS days from today (instead of today). + -E --update When adding a key, update the expiration date if the + public key already exists with an other expiration + date. + -f --file Use a file instead of a pattern (for del) -H URI... --uri=URI... URI of the LDAP server to connect; loaded from the config file by default. If not defined even there, then it defaults to ldap://localhost. + -j --json Output in json format (only for listall) + -m MAX --max=MAX Max count of keys included in FILE that is allowed to + be processed. If greater than MAX, no operation is + performed (checked in add/update/sync). + -p --purge In sync mode, purge stored entries instead of expiring + them when necessary. -q --quiet Be quiet. -u LOGIN --user=LOGIN Login of the user to bind as and change public key(s) (default is the current user). + -U --uid Only display uid (only for listall) -v --version Show version information. + -V VALIDITY --validity=VALIDITY + VALIDITY can be: 'all' to list all public keys, + 'valid' to list only valid (i.e. unexpired) public + key(s), 'invalid' to list public keys not having an + expiration date, 'expired' to list expired public + key(s) or 'expire' to list key(s) still valid but + that will be expired in DAYS days from today having + DAYS provided using -e DAYS, [default: all]. -h --help Show this message. """ from __future__ import print_function +import json import sys from docopt import docopt from getpass import getpass, getuser from os import access, R_OK -from ssh_ldap_pubkey import LdapSSH, Error, keyname +from ssh_ldap_pubkey import LdapSSH, Error, keyname, PubKeyAlreadyExistsError from ssh_ldap_pubkey.config import LdapConfig from ssh_ldap_pubkey import __versionstr__ @@ -65,6 +94,12 @@ def read_stdin(): return ''.join(sys.stdin.readlines()).strip() +def check_maxkeys(pubkeys, maxkeys=None, msg="more keys than allowed for this operation."): + """Check that the amount of pubkeys to process respects the limitation or abort.""" + if maxkeys is not None and len(pubkeys.splitlines()) > maxkeys: + halt(msg, code=2) + + def halt(msg, code=1): """Print error message to stderr and exit with the specified code.""" print('Error: ' + msg, file=sys.stderr) @@ -84,6 +119,9 @@ def main(**kwargs): confpath = kwargs['--conf'] or DEFAULT_CONFIG_PATH login = kwargs['--user'] or getuser() passw = None + maxkeys = None + if kwargs['--max']: + maxkeys = int(kwargs['--max']) if not access(confpath, R_OK): info("Notice: Could not read config: %s; running with defaults.", confpath) @@ -94,6 +132,22 @@ def main(**kwargs): conf.uris = kwargs['--uri'] if kwargs['--base']: conf.base = kwargs['--base'] + if kwargs['--expire']: + conf.expire = int(kwargs['--expire']) + else: + conf.expire = None + conf.purge = kwargs['--purge'] + + if kwargs['--uid']: + conf.attrs = [conf.login_attr] + elif kwargs['--json']: + conf.attrs = [a.strip() for a in kwargs['--attrs'].split(',')] + [conf.pubkey_attr] + else: + conf.attrs = [conf.pubkey_attr] + + conf.validity = kwargs['--validity'].lower() + if conf.validity not in ('valid', 'invalid', 'expire', 'expired'): + conf.validity = 'all' # prompt for password if kwargs['--binddn']: @@ -109,18 +163,67 @@ def main(**kwargs): if kwargs['add']: filesrc = kwargs['FILE'] and kwargs['FILE'] != '-' - pubkey = read_file(kwargs['FILE']) if filesrc else read_stdin() - - ldapssh.add_pubkey(login, passw, pubkey) - info("Key has been stored: %s", keyname(pubkey)) + pubkeys = read_file(kwargs['FILE']) if filesrc else read_stdin() + check_maxkeys(pubkeys, maxkeys, + "more keys than allowed for add in %s." % kwargs['FILE']) + + for pubkey in pubkeys.splitlines(): + if kwargs['--update']: + deleted = ldapssh.find_and_update_pubkey(login, passw, pubkey) + info("Key %s has been updated (%d obsolete entry removed)" % + (keyname(pubkey), len(deleted))) + else: + try: + ldapssh.add_pubkey(login, passw, pubkey) + info("Key has been stored: %s", keyname(pubkey)) + except PubKeyAlreadyExistsError as e: + info(e.args[0]) + + elif kwargs['sync']: + filesrc = kwargs['FILE'] and kwargs['FILE'] != '-' + pubkeys = read_file(kwargs['FILE']) if filesrc else read_stdin() + check_maxkeys(pubkeys, maxkeys, + "more keys than allowed for sync in %s." % kwargs['FILE']) + if pubkeys: + ldapssh.sync_pubkeys(login, passw, pubkeys) + else: + info("No key to sync found in %s." % kwargs['FILE']) elif kwargs['del']: - keys = ldapssh.find_and_remove_pubkeys(login, passw, kwargs['PATTERN']) - if keys: - info('Deleted keys:') - print('\n'.join(keys)) + if kwargs['--file']: + filesrc = kwargs['PATTERN'] and kwargs['PATTERN'] != '-' + pubkeys = read_file(kwargs['PATTERN']) if filesrc else read_stdin() + for pubkey in pubkeys.splitlines(): + rawkey = pubkey.split()[1] + keys = ldapssh.find_and_remove_pubkeys(login, passw, rawkey) + if keys: + info("Deleted keys for %s:" % keyname(pubkey)) + print('\n'.join(keys)) + else: + info("No key deleted for %s." % keyname(pubkey)) + if not pubkeys: + info("No key found to delete in %s." % kwargs['PATTERN']) + else: + keys = ldapssh.find_and_remove_pubkeys(login, passw, kwargs['PATTERN']) + if keys: + info('Deleted keys:') + print('\n'.join(keys)) + else: + info('No keys found to delete.') + + elif kwargs['listall']: + keys = ldapssh.find_all_pubkeys() + if kwargs['--json']: + if keys: + json.dump(keys, sys.stdout, indent=2) + else: + print('{}') + elif kwargs['--uid']: + if keys: + print('\n'.join(sorted([key[conf.login_attr][0] for key in keys]))) else: - info('No keys found to delete.') + if keys: + print('\n'.join(sorted(['\n'.join(sorted(key[conf.pubkey_attr])) for key in keys]))) else: # list keys = ldapssh.find_pubkeys(login) diff --git a/ssh_ldap_pubkey/__init__.py b/ssh_ldap_pubkey/__init__.py index 0868629..8f7fbf1 100644 --- a/ssh_ldap_pubkey/__init__.py +++ b/ssh_ldap_pubkey/__init__.py @@ -3,9 +3,11 @@ import base64 import ldap +import re import struct import sys +from datetime import date, datetime, timedelta from .exceptions import * @@ -22,7 +24,7 @@ def keyname(pubkey): - return pubkey.split()[-1] + return "_".join(pubkey.split()[2:]) def is_valid_openssh_pubkey(pubkey): @@ -61,6 +63,52 @@ def _encode(input): return input.encode('utf8') +def _decode_all(input): + if isinstance(input, dict): + return {_decode_all(key): _decode_all(value) + for key, value in input.items()} + elif isinstance(input, list): + return [_decode_all(element) for element in input] + elif not isinstance(input, (str)): + return _decode(input) + else: + return input + + +def _parse_expiration(s): + return datetime.strptime(s, '%Y-%m-%d').date() + + +def _calc_expiration(days): + expire = date.today() + timedelta(days=days) + return expire.strftime('%Y-%m-%d') + + +def _find_expiration(s): + m = re.search(r'expire=(\d{4}-\d{2}-\d{2})', s) + if m: + return _parse_expiration(m.group(1)) + return None + + +def _update_expiration(s, days): + return re.sub(r'expire=\d{4}-\d{2}-\d{2}', "expire=%s" % _calc_expiration(days), s) + + +def _add_expiration(pubkey, days): + fields = pubkey.split() + expiration = 'expire=%s' % _calc_expiration(days) + + if len(fields) == 2: + return ' '.join([pubkey, expiration]) + + comment = pubkey.split(' ', 2)[-1] + if _find_expiration(comment) is not None: + return _update_expiration(pubkey, days) + + return '%s %s' % (pubkey, expiration) + + class LdapSSH(object): def __init__(self, conf): @@ -124,7 +172,7 @@ def close(self): """Unbind from the LDAP server.""" self._conn and self._conn.unbind_s() - def add_pubkey(self, login, password, pubkey): + def add_pubkey(self, login, password, pubkey, update=False): """Add SSH public key to the user with the given ``login``. Arguments: @@ -132,6 +180,8 @@ def add_pubkey(self, login, password, pubkey): password (Optional[str]): The user's password to bind with, or None to not (re)bind with the user's credentials. pubkey (str): The public key to add. + update (bool): if `True`, update the expiration date of a pubkey if it already + exists. Raises: InvalidPubKeyError: If the ``pubkey`` is invalid. PubKeyAlreadyExistsError: If the user already has the given ``pubkey``. @@ -150,24 +200,115 @@ def add_pubkey(self, login, password, pubkey): if password: self._bind(dn, password) - if self._has_pubkey(dn, pubkey): + if not update and self._has_pubkey(dn, pubkey): raise PubKeyAlreadyExistsError( "Public key %s already exists." % keyname(pubkey), 1) - modlist = [(ldap.MOD_ADD, conf.pubkey_attr, _encode(pubkey))] - try: - self._conn.modify_s(dn, modlist) + self._add_pubkey(dn, pubkey, conf.expire) - except ldap.OBJECT_CLASS_VIOLATION: - modlist += [(ldap.MOD_ADD, 'objectClass', _encode(conf.pubkey_class))] - self._conn.modify_s(dn, modlist) + def sync_pubkeys(self, login, password, synckeys): + """Sync SSH public keys to the user with the given ``login``. - except ldap.UNDEFINED_TYPE: - raise ConfigError( - "LDAP server doesn't define schema for attribute: %s" % conf.pubkey_attr, 1) + Arguments: + login (str): Login of the user to add the ``pubkey``. + password (Optional[str]): The user's password to bind with, or None + to not (re)bind with the user's credentials. + synckeys (List[str]): The public keys to sync. + Raises: + InvalidPubKeyError: If the ``pubkey`` is invalid. + UserEntryNotFoundError: If the ``login`` is not found. + ConfigError: If LDAP server doesn't define schema for the attribute specified + in the config. + InsufficientAccessError: If the bind user doesn't have rights to add the pubkey. + ldap.LDAPError: + """ + conf = self.conf - except ldap.INSUFFICIENT_ACCESS: - raise InsufficientAccessError("No rights to add key for %s " % dn, 2) + for key in synckeys.splitlines(): + if not is_valid_openssh_pubkey(key): + raise InvalidPubKeyError('Invalid key, not in OpenSSH Public Key format.', 1) + + dn = self.find_dn_by_login(login) + if password: + self._bind(dn, password) + + # grab stored pubkeys and valid pubkeys subset + valid_pubkeys = [] + pubkeys = list(self._find_pubkeys(dn)) + if pubkeys: + valid_pubkeys = self._filter(pubkeys, validity="valid", expire=None) + + # split synckeys in two groups: + # - keys already stored in LDAP + # - keys that need to be added + keys_already_present = [] + keys_to_add = [] + for ukey in synckeys.splitlines(): + rawkey = ukey.split()[1] + keys = [key for key in pubkeys if rawkey in key] + if keys: + keys_already_present += keys + else: + keys_to_add.append(ukey) + + # total amount of valid keys could be checked here before proceeding + + # add new keys + for key in keys_to_add: + self._add_pubkey(dn, key, self.conf.expire) + print("Key has been added: %s" % keyname(key)) + + # log already present entries + for key in keys_already_present: + print("Key already stored: %s" % keyname(key)) + + # expire/remove valid keys no longer in the user authorized_keys + for ukey in set(valid_pubkeys) - set(keys_already_present): + self._remove_pubkey(dn, ukey) + if conf.purge or _find_expiration(ukey) is None: + print("Key has been removed: %s" % keyname(ukey)) + else: + self._add_pubkey(dn, ukey, expire=-1) + print("Key has been expired: %s" % keyname(ukey)) + + # ignore/remove stored keys (not being considered as valid) + for ukey in set(pubkeys) - set(valid_pubkeys): + if conf.purge or _find_expiration(ukey) is None: + self._remove_pubkey(dn, ukey) + print("Key has been removed: %s" % keyname(ukey)) + + def find_and_update_pubkey(self, login, password, ukey): + """Find and update public keys of the user with ``login`` that have the same raw key as + ``ukey``. + + Arguments: + login (str): Login of the user to add the ``pubkey``. + password (Optional[str]): The user's password to bind with, or None + to not (re)bind with the user's credentials. + ukey (str): The key to be updated. + Raises: + UserEntryNotFoundError: If the ``login`` is not found. + InsufficientAccessError: If the bind user doesn't have rights to add the pubkey. + ldap.LDAPError: + Returns: + List[str]: A list of removed public keys. + """ + dn = self.find_dn_by_login(login) + if password: + self._bind(dn, password) + + rawkey = ukey.split()[1] + pubkeys = [key for key in self._find_pubkeys(dn) if rawkey in key] + try: + self.add_pubkey(login, password, ukey, True) + except PubKeyAlreadyExistsError: + # in case we are updating with the exact same key (& expiration) + # just return in order to avoid removing what we want to add + return [] + for key in pubkeys: + self._remove_pubkey(dn, key) + + return pubkeys def find_and_remove_pubkeys(self, login, password, pattern): """Find and remove public keys of the user with the ``login`` that maches the ``pattern``. @@ -177,6 +318,7 @@ def find_and_remove_pubkeys(self, login, password, pattern): password (Optional[str]): The user's password to bind with, or None to not (re)bind with the user's credentials. pattern (str): The pattern specifying public keys to be removed. + '*' means all public keys (wildcard). Raises: UserEntryNotFoundError: If the ``login`` is not found. NoPubKeyFoundError: If no public key matching the ``pattern`` is found. @@ -189,7 +331,7 @@ def find_and_remove_pubkeys(self, login, password, pattern): if password: self._bind(dn, password) - pubkeys = [key for key in self._find_pubkeys(dn) if pattern in key] + pubkeys = [key for key in self._find_pubkeys(dn) if pattern == '*' or pattern in key] for key in pubkeys: self._remove_pubkey(dn, key) @@ -206,7 +348,34 @@ def find_pubkeys(self, login): UserEntryNotFoundError: If the ``login`` is not found. ldap.LDAPError: """ - return self._find_pubkeys(self.find_dn_by_login(login)) + conf = self.conf + pubkeys = self._find_pubkeys(self.find_dn_by_login(login)) + + if conf.validity != 'all': + return self._filter(pubkeys, validity=conf.validity, + expire=conf.expire) + + return pubkeys + + def find_all_pubkeys(self): + """Return public keys of all users. + + Returns: + List[str]: A list of public keys. + Raises: + ldap.LDAPError: + """ + conf = self.conf + elements = self._find_all_pubkeys() + + if conf.validity != 'all': + for e in elements: + e[conf.pubkey_attr] = self._filter(e[conf.pubkey_attr], + validity=conf.validity, + expire=conf.expire) + return [e for e in elements if len(e[conf.pubkey_attr]) != 0] + + return elements def find_dn_by_login(self, login): """Returns Distinguished Name (DN) of the user with the given ``login``. @@ -252,6 +421,26 @@ def _bind(self, dn, password): def _bind_sasl_gssapi(self): self._conn.sasl_interactive_bind_s('', ldap.sasl.sasl({}, 'GSSAPI')) + def _filter(self, pubkeys, validity=None, expire=None): + filtered = [] + day = today = date.today() + if expire is not None: + day += timedelta(expire) + for key in pubkeys: + expiration = _find_expiration(key) + if validity == 'invalid' and (expiration is None): + filtered.append(key) + elif validity == 'expired' and (expiration is not None and day > expiration): + filtered.append(key) + elif validity == 'valid' and (expiration is not None and day <= expiration): + filtered.append(key) + elif validity == 'expire' and ( + expiration is not None and expiration >= today and day > expiration + ): + filtered.append(key) + + return filtered + def _find_pubkeys(self, dn): conf = self.conf result = self._conn.search_s( @@ -259,6 +448,13 @@ def _find_pubkeys(self, dn): return map(_decode, result[0][1].get(conf.pubkey_attr, [])) + def _find_all_pubkeys(self): + conf = self.conf + result = self._conn.search_s( + conf.base, ldap.SCOPE_SUBTREE, '(%s=*)' % conf.pubkey_attr, conf.attrs) + + return _decode_all([r[1] for r in result]) + def _has_pubkey(self, dn, pubkey): current = self._find_pubkeys(dn) is_same_key = lambda k1, k2: k1.split()[1] == k2.split()[1] @@ -281,3 +477,28 @@ def _remove_pubkey(self, dn, pubkey): except ldap.INSUFFICIENT_ACCESS: raise InsufficientAccessError("No rights to remove key for %s " % dn, 2) + + def _add_pubkey(self, dn, pubkey, expire): + conf = self.conf + + if expire is not None: + pubkey = _add_expiration(pubkey, expire) + + modlist = [(ldap.MOD_ADD, conf.pubkey_attr, _encode(pubkey))] + try: + self._conn.modify_s(dn, modlist) + + except ldap.OBJECT_CLASS_VIOLATION: + modlist += [(ldap.MOD_ADD, 'objectClass', _encode(conf.pubkey_class))] + self._conn.modify_s(dn, modlist) + + except ldap.TYPE_OR_VALUE_EXISTS: + raise PubKeyAlreadyExistsError( + "Public key %s already exists while adding it." % keyname(pubkey), 1) + + except ldap.UNDEFINED_TYPE: + raise ConfigError( + "LDAP server doesn't define schema for attribute: %s" % conf.pubkey_attr, 1) + + except ldap.INSUFFICIENT_ACCESS: + raise InsufficientAccessError("No rights to add key for %s " % dn, 2) From 2d9794abdb0bbce2a46fb2fcb5e28ab23fade761 Mon Sep 17 00:00:00 2001 From: Cyril Servant Date: Thu, 15 Feb 2024 15:50:59 +0100 Subject: [PATCH 2/2] bugfix when "-V valid" and "-U" are used simultaneously --- bin/ssh-ldap-pubkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ssh-ldap-pubkey b/bin/ssh-ldap-pubkey index ca94ac9..c6fba6f 100755 --- a/bin/ssh-ldap-pubkey +++ b/bin/ssh-ldap-pubkey @@ -139,7 +139,7 @@ def main(**kwargs): conf.purge = kwargs['--purge'] if kwargs['--uid']: - conf.attrs = [conf.login_attr] + conf.attrs = [conf.login_attr] + [conf.pubkey_attr] elif kwargs['--json']: conf.attrs = [a.strip() for a in kwargs['--attrs'].split(',')] + [conf.pubkey_attr] else: